detached 3795579

feat(palette): add argument previews and ephemeral sessions

commit
3795579c930c66a70ee8233a94f0650bd3fd19b4
author
brice · · 2026-08-11 14:23 UTC
parent
bb3ed31
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +481 -35
M internal/usecase/command/command.go +15 -7
@@ -39,6 +39,8 @@ func ParsePositiveDecimal(args []string) (int, error) {
3939 type Context interface {
4040 CreateTab() error
4141 CreateSession() error
42+ CreateSessionNamed(name string) error
43+ CreateEphemeralSession() error
4244 CloseTab() error
4345 SplitRight() error
4446 SplitLeft() error
@@ -68,7 +70,9 @@ type Context interface {
6870 Detach() error
6971 EnterVisualMode() error
7072 RenameSession() error
73+ RenameSessionTo(name string) error
7174 RenameTab() error
75+ RenameTabTo(name string) error
7276 OpenSessionPicker() error
7377 OpenNotifications() error
7478 YankLastNotification() error
@@ -80,6 +84,7 @@ type Arguments uint8
8084
8185 const (
8286 ArgumentsNone Arguments = iota
87+ ArgumentsOptional
8388 ArgumentsRequired
8489 )
8590
@@ -163,11 +168,14 @@ type Command struct {
163168 Slug, Code, Name, Desc string
164169 Usage string
165170 Arguments Arguments
166- ContextHint ContextHint
167- Scriptable bool
168- PaletteVisible bool
169- Target TargetKind
170- Scope CommandScope
171- Run func(Context, []string) error
172- Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
171+ // Preview describes the selected palette command while its exact token is
172+ // being entered. hasArgument is true after whitespace following the token.
173+ Preview func(args []string, hasArgument bool) string
174+ ContextHint ContextHint
175+ Scriptable bool
176+ PaletteVisible bool
177+ Target TargetKind
178+ Scope CommandScope
179+ Run func(Context, []string) error
180+ Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
173181 }
@@ -39,6 +39,8 @@ func ParsePositiveDecimal(args []string) (int, error) {
39 type Context interface {39 type Context interface {
40 CreateTab() error40 CreateTab() error
41 CreateSession() error41 CreateSession() error
42+ CreateSessionNamed(name string) error
43+ CreateEphemeralSession() error
42 CloseTab() error44 CloseTab() error
43 SplitRight() error45 SplitRight() error
44 SplitLeft() error46 SplitLeft() error
@@ -68,7 +70,9 @@ type Context interface {
68 Detach() error70 Detach() error
69 EnterVisualMode() error71 EnterVisualMode() error
70 RenameSession() error72 RenameSession() error
73+ RenameSessionTo(name string) error
71 RenameTab() error74 RenameTab() error
75+ RenameTabTo(name string) error
72 OpenSessionPicker() error76 OpenSessionPicker() error
73 OpenNotifications() error77 OpenNotifications() error
74 YankLastNotification() error78 YankLastNotification() error
@@ -80,6 +84,7 @@ type Arguments uint8
80 84
81 const (85 const (
82 ArgumentsNone Arguments = iota86 ArgumentsNone Arguments = iota
87+ ArgumentsOptional
83 ArgumentsRequired88 ArgumentsRequired
84 )89 )
85 90
@@ -163,11 +168,14 @@ type Command struct {
163 Slug, Code, Name, Desc string168 Slug, Code, Name, Desc string
164 Usage string169 Usage string
165 Arguments Arguments170 Arguments Arguments
166- ContextHint ContextHint171+ // Preview describes the selected palette command while its exact token is
167- Scriptable bool172+ // being entered. hasArgument is true after whitespace following the token.
168- PaletteVisible bool173+ Preview func(args []string, hasArgument bool) string
169- Target TargetKind174+ ContextHint ContextHint
170- Scope CommandScope175+ Scriptable bool
171- Run func(Context, []string) error176+ PaletteVisible bool
172- Control func(ControlContext, []string, ControlOptions) (ControlResult, error)177+ Target TargetKind
178+ Scope CommandScope
179+ Run func(Context, []string) error
180+ Control func(ControlContext, []string, ControlOptions) (ControlResult, error)
173 }181 }
M internal/usecase/command/context_mock_test.go +197 -0
@@ -255,6 +255,50 @@ func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() erro
255255 return _c
256256 }
257257
258+// CreateEphemeralSession provides a mock function for the type MockContext
259+func (_mock *MockContext) CreateEphemeralSession() error {
260+ ret := _mock.Called()
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for CreateEphemeralSession")
264+ }
265+
266+ var r0 error
267+ if returnFunc, ok := ret.Get(0).(func() error); ok {
268+ r0 = returnFunc()
269+ } else {
270+ r0 = ret.Error(0)
271+ }
272+ return r0
273+}
274+
275+// MockContext_CreateEphemeralSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEphemeralSession'
276+type MockContext_CreateEphemeralSession_Call struct {
277+ *mock.Call
278+}
279+
280+// CreateEphemeralSession is a helper method to define mock.On call
281+func (_e *MockContext_Expecter) CreateEphemeralSession() *MockContext_CreateEphemeralSession_Call {
282+ return &MockContext_CreateEphemeralSession_Call{Call: _e.mock.On("CreateEphemeralSession")}
283+}
284+
285+func (_c *MockContext_CreateEphemeralSession_Call) Run(run func()) *MockContext_CreateEphemeralSession_Call {
286+ _c.Call.Run(func(args mock.Arguments) {
287+ run()
288+ })
289+ return _c
290+}
291+
292+func (_c *MockContext_CreateEphemeralSession_Call) Return(err error) *MockContext_CreateEphemeralSession_Call {
293+ _c.Call.Return(err)
294+ return _c
295+}
296+
297+func (_c *MockContext_CreateEphemeralSession_Call) RunAndReturn(run func() error) *MockContext_CreateEphemeralSession_Call {
298+ _c.Call.Return(run)
299+ return _c
300+}
301+
258302 // CreateSession provides a mock function for the type MockContext
259303 func (_mock *MockContext) CreateSession() error {
260304 ret := _mock.Called()
@@ -299,6 +343,57 @@ func (_c *MockContext_CreateSession_Call) RunAndReturn(run func() error) *MockCo
299343 return _c
300344 }
301345
346+// CreateSessionNamed provides a mock function for the type MockContext
347+func (_mock *MockContext) CreateSessionNamed(name string) error {
348+ ret := _mock.Called(name)
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for CreateSessionNamed")
352+ }
353+
354+ var r0 error
355+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
356+ r0 = returnFunc(name)
357+ } else {
358+ r0 = ret.Error(0)
359+ }
360+ return r0
361+}
362+
363+// MockContext_CreateSessionNamed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSessionNamed'
364+type MockContext_CreateSessionNamed_Call struct {
365+ *mock.Call
366+}
367+
368+// CreateSessionNamed is a helper method to define mock.On call
369+// - name string
370+func (_e *MockContext_Expecter) CreateSessionNamed(name any) *MockContext_CreateSessionNamed_Call {
371+ return &MockContext_CreateSessionNamed_Call{Call: _e.mock.On("CreateSessionNamed", name)}
372+}
373+
374+func (_c *MockContext_CreateSessionNamed_Call) Run(run func(name string)) *MockContext_CreateSessionNamed_Call {
375+ _c.Call.Run(func(args mock.Arguments) {
376+ var arg0 string
377+ if args[0] != nil {
378+ arg0 = args[0].(string)
379+ }
380+ run(
381+ arg0,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *MockContext_CreateSessionNamed_Call) Return(err error) *MockContext_CreateSessionNamed_Call {
388+ _c.Call.Return(err)
389+ return _c
390+}
391+
392+func (_c *MockContext_CreateSessionNamed_Call) RunAndReturn(run func(name string) error) *MockContext_CreateSessionNamed_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
302397 // CreateTab provides a mock function for the type MockContext
303398 func (_mock *MockContext) CreateTab() error {
304399 ret := _mock.Called()
@@ -1142,6 +1237,57 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
11421237 return _c
11431238 }
11441239
1240+// RenameSessionTo provides a mock function for the type MockContext
1241+func (_mock *MockContext) RenameSessionTo(name string) error {
1242+ ret := _mock.Called(name)
1243+
1244+ if len(ret) == 0 {
1245+ panic("no return value specified for RenameSessionTo")
1246+ }
1247+
1248+ var r0 error
1249+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1250+ r0 = returnFunc(name)
1251+ } else {
1252+ r0 = ret.Error(0)
1253+ }
1254+ return r0
1255+}
1256+
1257+// MockContext_RenameSessionTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameSessionTo'
1258+type MockContext_RenameSessionTo_Call struct {
1259+ *mock.Call
1260+}
1261+
1262+// RenameSessionTo is a helper method to define mock.On call
1263+// - name string
1264+func (_e *MockContext_Expecter) RenameSessionTo(name any) *MockContext_RenameSessionTo_Call {
1265+ return &MockContext_RenameSessionTo_Call{Call: _e.mock.On("RenameSessionTo", name)}
1266+}
1267+
1268+func (_c *MockContext_RenameSessionTo_Call) Run(run func(name string)) *MockContext_RenameSessionTo_Call {
1269+ _c.Call.Run(func(args mock.Arguments) {
1270+ var arg0 string
1271+ if args[0] != nil {
1272+ arg0 = args[0].(string)
1273+ }
1274+ run(
1275+ arg0,
1276+ )
1277+ })
1278+ return _c
1279+}
1280+
1281+func (_c *MockContext_RenameSessionTo_Call) Return(err error) *MockContext_RenameSessionTo_Call {
1282+ _c.Call.Return(err)
1283+ return _c
1284+}
1285+
1286+func (_c *MockContext_RenameSessionTo_Call) RunAndReturn(run func(name string) error) *MockContext_RenameSessionTo_Call {
1287+ _c.Call.Return(run)
1288+ return _c
1289+}
1290+
11451291 // RenameTab provides a mock function for the type MockContext
11461292 func (_mock *MockContext) RenameTab() error {
11471293 ret := _mock.Called()
@@ -1186,6 +1332,57 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex
11861332 return _c
11871333 }
11881334
1335+// RenameTabTo provides a mock function for the type MockContext
1336+func (_mock *MockContext) RenameTabTo(name string) error {
1337+ ret := _mock.Called(name)
1338+
1339+ if len(ret) == 0 {
1340+ panic("no return value specified for RenameTabTo")
1341+ }
1342+
1343+ var r0 error
1344+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1345+ r0 = returnFunc(name)
1346+ } else {
1347+ r0 = ret.Error(0)
1348+ }
1349+ return r0
1350+}
1351+
1352+// MockContext_RenameTabTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTabTo'
1353+type MockContext_RenameTabTo_Call struct {
1354+ *mock.Call
1355+}
1356+
1357+// RenameTabTo is a helper method to define mock.On call
1358+// - name string
1359+func (_e *MockContext_Expecter) RenameTabTo(name any) *MockContext_RenameTabTo_Call {
1360+ return &MockContext_RenameTabTo_Call{Call: _e.mock.On("RenameTabTo", name)}
1361+}
1362+
1363+func (_c *MockContext_RenameTabTo_Call) Run(run func(name string)) *MockContext_RenameTabTo_Call {
1364+ _c.Call.Run(func(args mock.Arguments) {
1365+ var arg0 string
1366+ if args[0] != nil {
1367+ arg0 = args[0].(string)
1368+ }
1369+ run(
1370+ arg0,
1371+ )
1372+ })
1373+ return _c
1374+}
1375+
1376+func (_c *MockContext_RenameTabTo_Call) Return(err error) *MockContext_RenameTabTo_Call {
1377+ _c.Call.Return(err)
1378+ return _c
1379+}
1380+
1381+func (_c *MockContext_RenameTabTo_Call) RunAndReturn(run func(name string) error) *MockContext_RenameTabTo_Call {
1382+ _c.Call.Return(run)
1383+ return _c
1384+}
1385+
11891386 // ShrinkPaneHeight provides a mock function for the type MockContext
11901387 func (_mock *MockContext) ShrinkPaneHeight() error {
11911388 ret := _mock.Called()
@@ -255,6 +255,50 @@ func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() erro
255 return _c255 return _c
256 }256 }
257 257
258+// CreateEphemeralSession provides a mock function for the type MockContext
259+func (_mock *MockContext) CreateEphemeralSession() error {
260+ ret := _mock.Called()
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for CreateEphemeralSession")
264+ }
265+
266+ var r0 error
267+ if returnFunc, ok := ret.Get(0).(func() error); ok {
268+ r0 = returnFunc()
269+ } else {
270+ r0 = ret.Error(0)
271+ }
272+ return r0
273+}
274+
275+// MockContext_CreateEphemeralSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateEphemeralSession'
276+type MockContext_CreateEphemeralSession_Call struct {
277+ *mock.Call
278+}
279+
280+// CreateEphemeralSession is a helper method to define mock.On call
281+func (_e *MockContext_Expecter) CreateEphemeralSession() *MockContext_CreateEphemeralSession_Call {
282+ return &MockContext_CreateEphemeralSession_Call{Call: _e.mock.On("CreateEphemeralSession")}
283+}
284+
285+func (_c *MockContext_CreateEphemeralSession_Call) Run(run func()) *MockContext_CreateEphemeralSession_Call {
286+ _c.Call.Run(func(args mock.Arguments) {
287+ run()
288+ })
289+ return _c
290+}
291+
292+func (_c *MockContext_CreateEphemeralSession_Call) Return(err error) *MockContext_CreateEphemeralSession_Call {
293+ _c.Call.Return(err)
294+ return _c
295+}
296+
297+func (_c *MockContext_CreateEphemeralSession_Call) RunAndReturn(run func() error) *MockContext_CreateEphemeralSession_Call {
298+ _c.Call.Return(run)
299+ return _c
300+}
301+
258 // CreateSession provides a mock function for the type MockContext302 // CreateSession provides a mock function for the type MockContext
259 func (_mock *MockContext) CreateSession() error {303 func (_mock *MockContext) CreateSession() error {
260 ret := _mock.Called()304 ret := _mock.Called()
@@ -299,6 +343,57 @@ func (_c *MockContext_CreateSession_Call) RunAndReturn(run func() error) *MockCo
299 return _c343 return _c
300 }344 }
301 345
346+// CreateSessionNamed provides a mock function for the type MockContext
347+func (_mock *MockContext) CreateSessionNamed(name string) error {
348+ ret := _mock.Called(name)
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for CreateSessionNamed")
352+ }
353+
354+ var r0 error
355+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
356+ r0 = returnFunc(name)
357+ } else {
358+ r0 = ret.Error(0)
359+ }
360+ return r0
361+}
362+
363+// MockContext_CreateSessionNamed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateSessionNamed'
364+type MockContext_CreateSessionNamed_Call struct {
365+ *mock.Call
366+}
367+
368+// CreateSessionNamed is a helper method to define mock.On call
369+// - name string
370+func (_e *MockContext_Expecter) CreateSessionNamed(name any) *MockContext_CreateSessionNamed_Call {
371+ return &MockContext_CreateSessionNamed_Call{Call: _e.mock.On("CreateSessionNamed", name)}
372+}
373+
374+func (_c *MockContext_CreateSessionNamed_Call) Run(run func(name string)) *MockContext_CreateSessionNamed_Call {
375+ _c.Call.Run(func(args mock.Arguments) {
376+ var arg0 string
377+ if args[0] != nil {
378+ arg0 = args[0].(string)
379+ }
380+ run(
381+ arg0,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *MockContext_CreateSessionNamed_Call) Return(err error) *MockContext_CreateSessionNamed_Call {
388+ _c.Call.Return(err)
389+ return _c
390+}
391+
392+func (_c *MockContext_CreateSessionNamed_Call) RunAndReturn(run func(name string) error) *MockContext_CreateSessionNamed_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
302 // CreateTab provides a mock function for the type MockContext397 // CreateTab provides a mock function for the type MockContext
303 func (_mock *MockContext) CreateTab() error {398 func (_mock *MockContext) CreateTab() error {
304 ret := _mock.Called()399 ret := _mock.Called()
@@ -1142,6 +1237,57 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
1142 return _c1237 return _c
1143 }1238 }
1144 1239
1240+// RenameSessionTo provides a mock function for the type MockContext
1241+func (_mock *MockContext) RenameSessionTo(name string) error {
1242+ ret := _mock.Called(name)
1243+
1244+ if len(ret) == 0 {
1245+ panic("no return value specified for RenameSessionTo")
1246+ }
1247+
1248+ var r0 error
1249+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1250+ r0 = returnFunc(name)
1251+ } else {
1252+ r0 = ret.Error(0)
1253+ }
1254+ return r0
1255+}
1256+
1257+// MockContext_RenameSessionTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameSessionTo'
1258+type MockContext_RenameSessionTo_Call struct {
1259+ *mock.Call
1260+}
1261+
1262+// RenameSessionTo is a helper method to define mock.On call
1263+// - name string
1264+func (_e *MockContext_Expecter) RenameSessionTo(name any) *MockContext_RenameSessionTo_Call {
1265+ return &MockContext_RenameSessionTo_Call{Call: _e.mock.On("RenameSessionTo", name)}
1266+}
1267+
1268+func (_c *MockContext_RenameSessionTo_Call) Run(run func(name string)) *MockContext_RenameSessionTo_Call {
1269+ _c.Call.Run(func(args mock.Arguments) {
1270+ var arg0 string
1271+ if args[0] != nil {
1272+ arg0 = args[0].(string)
1273+ }
1274+ run(
1275+ arg0,
1276+ )
1277+ })
1278+ return _c
1279+}
1280+
1281+func (_c *MockContext_RenameSessionTo_Call) Return(err error) *MockContext_RenameSessionTo_Call {
1282+ _c.Call.Return(err)
1283+ return _c
1284+}
1285+
1286+func (_c *MockContext_RenameSessionTo_Call) RunAndReturn(run func(name string) error) *MockContext_RenameSessionTo_Call {
1287+ _c.Call.Return(run)
1288+ return _c
1289+}
1290+
1145 // RenameTab provides a mock function for the type MockContext1291 // RenameTab provides a mock function for the type MockContext
1146 func (_mock *MockContext) RenameTab() error {1292 func (_mock *MockContext) RenameTab() error {
1147 ret := _mock.Called()1293 ret := _mock.Called()
@@ -1186,6 +1332,57 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex
1186 return _c1332 return _c
1187 }1333 }
1188 1334
1335+// RenameTabTo provides a mock function for the type MockContext
1336+func (_mock *MockContext) RenameTabTo(name string) error {
1337+ ret := _mock.Called(name)
1338+
1339+ if len(ret) == 0 {
1340+ panic("no return value specified for RenameTabTo")
1341+ }
1342+
1343+ var r0 error
1344+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
1345+ r0 = returnFunc(name)
1346+ } else {
1347+ r0 = ret.Error(0)
1348+ }
1349+ return r0
1350+}
1351+
1352+// MockContext_RenameTabTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTabTo'
1353+type MockContext_RenameTabTo_Call struct {
1354+ *mock.Call
1355+}
1356+
1357+// RenameTabTo is a helper method to define mock.On call
1358+// - name string
1359+func (_e *MockContext_Expecter) RenameTabTo(name any) *MockContext_RenameTabTo_Call {
1360+ return &MockContext_RenameTabTo_Call{Call: _e.mock.On("RenameTabTo", name)}
1361+}
1362+
1363+func (_c *MockContext_RenameTabTo_Call) Run(run func(name string)) *MockContext_RenameTabTo_Call {
1364+ _c.Call.Run(func(args mock.Arguments) {
1365+ var arg0 string
1366+ if args[0] != nil {
1367+ arg0 = args[0].(string)
1368+ }
1369+ run(
1370+ arg0,
1371+ )
1372+ })
1373+ return _c
1374+}
1375+
1376+func (_c *MockContext_RenameTabTo_Call) Return(err error) *MockContext_RenameTabTo_Call {
1377+ _c.Call.Return(err)
1378+ return _c
1379+}
1380+
1381+func (_c *MockContext_RenameTabTo_Call) RunAndReturn(run func(name string) error) *MockContext_RenameTabTo_Call {
1382+ _c.Call.Return(run)
1383+ return _c
1384+}
1385+
1189 // ShrinkPaneHeight provides a mock function for the type MockContext1386 // ShrinkPaneHeight provides a mock function for the type MockContext
1190 func (_mock *MockContext) ShrinkPaneHeight() error {1387 func (_mock *MockContext) ShrinkPaneHeight() error {
1191 ret := _mock.Called()1388 ret := _mock.Called()
M internal/usecase/command/registry.go +35 -8
@@ -6,7 +6,8 @@ import "strings"
66 func Registry() []Command {
77 commands := []Command{
88 paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }),
9- paletteControlOne("new-session", "CNS", "New session", "Create and switch to a named session", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateSession() }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }),
9+ paletteOptionalOne("new-session", "CNS", "New session", "Create named session", TargetSession, func(ctx Context) error { return ctx.CreateSession() }, func(ctx Context, name string) error { return ctx.CreateSessionNamed(name) }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }),
10+ paletteOnly("create-ephemeral-session", "CES", "New ephemeral session", "Create ephemeral session", func(ctx Context, _ []string) error { return ctx.CreateEphemeralSession() }),
1011 paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }),
1112 paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }),
1213 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
@@ -29,10 +30,10 @@ func Registry() []Command {
2930 paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }),
3031 paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }),
3132 paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }),
32- paletteControl("equalize-panes", "EQP", "Equalize panes", "Restore equal pane shares in the tab", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }),
33+ paletteControl("equalize-panes", "EQP", "Equalize panes", "Equalize pane sizes", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }),
3334 paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),
3435 paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),
35- paletteOnly("back-session", "BCK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
36+ paletteOnly("back-session", "BCK", "Previous session", "Switch to previous session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
3637 {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
3738 rank, err := ParsePositiveDecimal(args)
3839 if err != nil {
@@ -42,10 +43,10 @@ func Registry() []Command {
4243 }},
4344 paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }),
4445 paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }),
45- paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy the most recent notification's details to the clipboard", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }),
46+ paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy last notice", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }),
4647 paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }),
47- paletteControlOne("rename-session", "RNS", "Rename session", "Rename the session (an ephemeral session becomes named)", TargetSession, func(ctx Context, _ []string) error { return ctx.RenameSession() }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }),
48- paletteControlOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.RenameTab() }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }),
48+ paletteOptionalOne("rename-session", "RNS", "Rename session", "Rename session", TargetSession, func(ctx Context) error { return ctx.RenameSession() }, func(ctx Context, name string) error { return ctx.RenameSessionTo(name) }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }),
49+ paletteOptionalOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context) error { return ctx.RenameTab() }, func(ctx Context, name string) error { return ctx.RenameTabTo(name) }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }),
4950 paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }),
5051 toastCommand(),
5152 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
@@ -70,8 +71,34 @@ func paletteControl(slug, code, name, desc string, target TargetKind, run func(C
7071 }}
7172 }
7273
73-func paletteControlOne(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext, string) error) Command {
74- cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " <name>", PaletteVisible: true, Scriptable: true, Target: target, Run: run}
74+func paletteOptionalOne(slug, code, name, desc string, target TargetKind, prompt func(Context) error, direct func(Context, string) error, control func(ControlContext, string) error) Command {
75+ cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " [name]", PaletteVisible: true, Scriptable: true, Target: target, Arguments: ArgumentsOptional}
76+ cmd.Preview = func(args []string, hasArgument bool) string {
77+ if len(args) == 0 {
78+ if hasArgument {
79+ return "Type a name, or press Enter"
80+ }
81+ return desc
82+ }
83+ switch slug {
84+ case "new-session":
85+ return "Create session “" + args[0] + "”"
86+ case "rename-session":
87+ return "Rename this session to “" + args[0] + "”"
88+ default:
89+ return "Rename this tab to “" + args[0] + "”"
90+ }
91+ }
92+ cmd.Run = func(ctx Context, args []string) error {
93+ switch len(args) {
94+ case 0:
95+ return prompt(ctx)
96+ case 1:
97+ return direct(ctx, args[0])
98+ default:
99+ return ErrInvalidArguments
100+ }
101+ }
75102 cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
76103 if len(args) != 1 || args[0] == "" {
77104 return ControlResult{}, ErrInvalidArguments
@@ -6,7 +6,8 @@ import "strings"
6 func Registry() []Command {6 func Registry() []Command {
7 commands := []Command{7 commands := []Command{
8 paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }),8 paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }),
9- paletteControlOne("new-session", "CNS", "New session", "Create and switch to a named session", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateSession() }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }),9+ paletteOptionalOne("new-session", "CNS", "New session", "Create named session", TargetSession, func(ctx Context) error { return ctx.CreateSession() }, func(ctx Context, name string) error { return ctx.CreateSessionNamed(name) }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }),
10+ paletteOnly("create-ephemeral-session", "CES", "New ephemeral session", "Create ephemeral session", func(ctx Context, _ []string) error { return ctx.CreateEphemeralSession() }),
10 paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }),11 paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }),
11 paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }),12 paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }),
12 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),13 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
@@ -29,10 +30,10 @@ func Registry() []Command {
29 paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }),30 paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }),
30 paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }),31 paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }),
31 paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }),32 paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }),
32- paletteControl("equalize-panes", "EQP", "Equalize panes", "Restore equal pane shares in the tab", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }),33+ paletteControl("equalize-panes", "EQP", "Equalize panes", "Equalize pane sizes", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }),
33 paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),34 paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }),
34 paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),35 paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }),
35- paletteOnly("back-session", "BCK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }),36+ paletteOnly("back-session", "BCK", "Previous session", "Switch to previous session", func(ctx Context, _ []string) error { return ctx.BackSession() }),
36 {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {37 {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error {
37 rank, err := ParsePositiveDecimal(args)38 rank, err := ParsePositiveDecimal(args)
38 if err != nil {39 if err != nil {
@@ -42,10 +43,10 @@ func Registry() []Command {
42 }},43 }},
43 paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }),44 paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }),
44 paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }),45 paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }),
45- paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy the most recent notification's details to the clipboard", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }),46+ paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy last notice", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }),
46 paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }),47 paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }),
47- paletteControlOne("rename-session", "RNS", "Rename session", "Rename the session (an ephemeral session becomes named)", TargetSession, func(ctx Context, _ []string) error { return ctx.RenameSession() }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }),48+ paletteOptionalOne("rename-session", "RNS", "Rename session", "Rename session", TargetSession, func(ctx Context) error { return ctx.RenameSession() }, func(ctx Context, name string) error { return ctx.RenameSessionTo(name) }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }),
48- paletteControlOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.RenameTab() }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }),49+ paletteOptionalOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context) error { return ctx.RenameTab() }, func(ctx Context, name string) error { return ctx.RenameTabTo(name) }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }),
49 paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }),50 paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }),
50 toastCommand(),51 toastCommand(),
51 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),52 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
@@ -70,8 +71,34 @@ func paletteControl(slug, code, name, desc string, target TargetKind, run func(C
70 }}71 }}
71 }72 }
72 73
73-func paletteControlOne(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext, string) error) Command {74+func paletteOptionalOne(slug, code, name, desc string, target TargetKind, prompt func(Context) error, direct func(Context, string) error, control func(ControlContext, string) error) Command {
74- cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " <name>", PaletteVisible: true, Scriptable: true, Target: target, Run: run}75+ cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " [name]", PaletteVisible: true, Scriptable: true, Target: target, Arguments: ArgumentsOptional}
76+ cmd.Preview = func(args []string, hasArgument bool) string {
77+ if len(args) == 0 {
78+ if hasArgument {
79+ return "Type a name, or press Enter"
80+ }
81+ return desc
82+ }
83+ switch slug {
84+ case "new-session":
85+ return "Create session “" + args[0] + "”"
86+ case "rename-session":
87+ return "Rename this session to “" + args[0] + "”"
88+ default:
89+ return "Rename this tab to “" + args[0] + "”"
90+ }
91+ }
92+ cmd.Run = func(ctx Context, args []string) error {
93+ switch len(args) {
94+ case 0:
95+ return prompt(ctx)
96+ case 1:
97+ return direct(ctx, args[0])
98+ default:
99+ return ErrInvalidArguments
100+ }
101+ }
75 cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {102 cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
76 if len(args) != 1 || args[0] == "" {103 if len(args) != 1 || args[0] == "" {
77 return ControlResult{}, ErrInvalidArguments104 return ControlResult{}, ErrInvalidArguments
M internal/usecase/command/registry_test.go +1 -1
@@ -9,7 +9,7 @@ import (
99
1010 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
1111 commands := PaletteRegistry()
12- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
12+ wantCodes := []string{"CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
1313
1414 if len(commands) != len(wantCodes) {
1515 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -9,7 +9,7 @@ import (
9 9
10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
11 commands := PaletteRegistry()11 commands := PaletteRegistry()
12- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}12+ wantCodes := []string{"CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
13 13
14 if len(commands) != len(wantCodes) {14 if len(commands) != len(wantCodes) {
15 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))15 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
M internal/usecase/daemon/overlay_runtime.go +3 -0
@@ -46,6 +46,7 @@ type overlayRuntime struct {
4646 paletteRouteSnapshot ports.RecentRouteSnapshot
4747 paletteGeneration uint64
4848 paletteHints palette.ContextualHints
49+ palettePreview string
4950 paletteFeedback string
5051 palettePending []byte
5152
@@ -306,6 +307,7 @@ type overlayRenderSnapshot struct {
306307 // paletteHints is a copy captured under paletteMu. Rendering must use this
307308 // immutable interaction snapshot rather than consult live session state.
308309 paletteHints *palette.ContextualHints
310+ palettePreview string
309311 paletteFeedback string
310312 paletteRouteSnapshot ports.RecentRouteSnapshot
311313 paletteLocked bool
@@ -378,6 +380,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
378380 hints := rt.paletteHints
379381 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
380382 snap.paletteHints = &hints
383+ snap.palettePreview = rt.palettePreview
381384 snap.paletteFeedback = rt.paletteFeedback
382385 snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383386 snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
@@ -46,6 +46,7 @@ type overlayRuntime struct {
46 paletteRouteSnapshot ports.RecentRouteSnapshot46 paletteRouteSnapshot ports.RecentRouteSnapshot
47 paletteGeneration uint6447 paletteGeneration uint64
48 paletteHints palette.ContextualHints48 paletteHints palette.ContextualHints
49+ palettePreview string
49 paletteFeedback string50 paletteFeedback string
50 palettePending []byte51 palettePending []byte
51 52
@@ -306,6 +307,7 @@ type overlayRenderSnapshot struct {
306 // paletteHints is a copy captured under paletteMu. Rendering must use this307 // paletteHints is a copy captured under paletteMu. Rendering must use this
307 // immutable interaction snapshot rather than consult live session state.308 // immutable interaction snapshot rather than consult live session state.
308 paletteHints *palette.ContextualHints309 paletteHints *palette.ContextualHints
310+ palettePreview string
309 paletteFeedback string311 paletteFeedback string
310 paletteRouteSnapshot ports.RecentRouteSnapshot312 paletteRouteSnapshot ports.RecentRouteSnapshot
311 paletteLocked bool313 paletteLocked bool
@@ -378,6 +380,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
378 hints := rt.paletteHints380 hints := rt.paletteHints
379 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)381 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
380 snap.paletteHints = &hints382 snap.paletteHints = &hints
383+ snap.palettePreview = rt.palettePreview
381 snap.paletteFeedback = rt.paletteFeedback384 snap.paletteFeedback = rt.paletteFeedback
382 snap.paletteRouteSnapshot = rt.paletteRouteSnapshot385 snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383 snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)386 snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
M internal/usecase/daemon/palette.go +41 -5
@@ -48,6 +48,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4848 ac.overlays.palette = palette.New(results)
4949 ac.overlays.paletteRouteSnapshot = routeSnapshot
5050 ac.overlays.paletteHints = palette.ContextualHints{}
51+ ac.overlays.palettePreview = ""
5152 ac.overlays.paletteFeedback = ""
5253 ac.overlays.palettePending = nil
5354 ac.overlays.paletteMu.Unlock()
@@ -266,15 +267,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
266267 rawQuery = ac.overlays.palette.Query()
267268 if selectedCommand, ok := selected.Command(); ok {
268269 cmd = selectedCommand
269- // JRS is contextual: fuzzy selection cannot turn an unrelated
270- // query into a jump.
271- if cmd.Slug == "jump-recent-session" {
270+ if cmd.Arguments != command.ArgumentsNone {
272271 action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
273- if !valid {
272+ if valid {
273+ args = action.Args
274+ } else if cmd.Arguments == command.ArgumentsRequired {
274275 changed = true
275276 return
276277 }
277- args = action.Args
278+ }
279+ if cmd.ContextHint == command.ContextHintRecentSessions {
278280 routeSnapshot = ac.overlays.paletteRouteSnapshot
279281 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
280282 }
@@ -294,7 +296,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
294296 })
295297 if changed {
296298 ac.overlays.paletteFeedback = ""
299+ ac.overlays.palettePreview = ""
297300 active, ok := ac.overlays.palette.ArgumentCommand()
301+ if ok {
302+ ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query())
303+ }
298304 if ok && active.Slug == "jump-recent-session" {
299305 args := paletteArgs(ac.overlays.palette.Query(), active)
300306 if ac.overlays.paletteRouteSnapshot.Generation != 0 {
@@ -445,6 +451,7 @@ func (ac *attachedClient) clearPaletteLocked() {
445451 ac.overlays.palette = nil
446452 ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
447453 ac.overlays.paletteHints = palette.ContextualHints{}
454+ ac.overlays.palettePreview = ""
448455 ac.overlays.paletteFeedback = ""
449456 ac.overlays.palettePending = nil
450457 }
@@ -525,6 +532,20 @@ func (e paletteExec) CreateSession() error {
525532 return nil
526533 }
527534
535+func (e paletteExec) CreateSessionNamed(name string) error {
536+ if e.effect == nil {
537+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
538+ }
539+ return e.d.createSessionAndSwitchForAttachment(e.effect.connectionToken(), name)
540+}
541+
542+func (e paletteExec) CreateEphemeralSession() error {
543+ if e.effect == nil {
544+ return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
545+ }
546+ return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect.connectionToken())
547+}
548+
528549 func (e paletteExec) CloseTab() error {
529550 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
530551 }
@@ -651,6 +672,10 @@ func (e paletteExec) RenameSession() error {
651672 return nil
652673 }
653674
675+func (e paletteExec) RenameSessionTo(name string) error {
676+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
677+}
678+
654679 func (e paletteExec) RenameTab() error {
655680 tb := e.sess.tabForAttachment(e.ac)
656681 if e.ac == nil {
@@ -675,6 +700,17 @@ func (e paletteExec) RenameTab() error {
675700 return nil
676701 }
677702
703+func (e paletteExec) RenameTabTo(name string) error {
704+ tb := e.sess.tabForAttachment(e.ac)
705+ if e.ac == nil {
706+ tb = e.sess.firstTab()
707+ }
708+ if tb == nil {
709+ return nil
710+ }
711+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
712+}
713+
678714 func (e paletteExec) OpenSessionPicker() error {
679715 if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
680716 return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
@@ -48,6 +48,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
48 ac.overlays.palette = palette.New(results)48 ac.overlays.palette = palette.New(results)
49 ac.overlays.paletteRouteSnapshot = routeSnapshot49 ac.overlays.paletteRouteSnapshot = routeSnapshot
50 ac.overlays.paletteHints = palette.ContextualHints{}50 ac.overlays.paletteHints = palette.ContextualHints{}
51+ ac.overlays.palettePreview = ""
51 ac.overlays.paletteFeedback = ""52 ac.overlays.paletteFeedback = ""
52 ac.overlays.palettePending = nil53 ac.overlays.palettePending = nil
53 ac.overlays.paletteMu.Unlock()54 ac.overlays.paletteMu.Unlock()
@@ -266,15 +267,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
266 rawQuery = ac.overlays.palette.Query()267 rawQuery = ac.overlays.palette.Query()
267 if selectedCommand, ok := selected.Command(); ok {268 if selectedCommand, ok := selected.Command(); ok {
268 cmd = selectedCommand269 cmd = selectedCommand
269- // JRS is contextual: fuzzy selection cannot turn an unrelated270+ if cmd.Arguments != command.ArgumentsNone {
270- // query into a jump.
271- if cmd.Slug == "jump-recent-session" {
272 action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)271 action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
273- if !valid {272+ if valid {
273+ args = action.Args
274+ } else if cmd.Arguments == command.ArgumentsRequired {
274 changed = true275 changed = true
275 return276 return
276 }277 }
277- args = action.Args278+ }
279+ if cmd.ContextHint == command.ContextHintRecentSessions {
278 routeSnapshot = ac.overlays.paletteRouteSnapshot280 routeSnapshot = ac.overlays.paletteRouteSnapshot
279 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)281 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
280 }282 }
@@ -294,7 +296,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
294 })296 })
295 if changed {297 if changed {
296 ac.overlays.paletteFeedback = ""298 ac.overlays.paletteFeedback = ""
299+ ac.overlays.palettePreview = ""
297 active, ok := ac.overlays.palette.ArgumentCommand()300 active, ok := ac.overlays.palette.ArgumentCommand()
301+ if ok {
302+ ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query())
303+ }
298 if ok && active.Slug == "jump-recent-session" {304 if ok && active.Slug == "jump-recent-session" {
299 args := paletteArgs(ac.overlays.palette.Query(), active)305 args := paletteArgs(ac.overlays.palette.Query(), active)
300 if ac.overlays.paletteRouteSnapshot.Generation != 0 {306 if ac.overlays.paletteRouteSnapshot.Generation != 0 {
@@ -445,6 +451,7 @@ func (ac *attachedClient) clearPaletteLocked() {
445 ac.overlays.palette = nil451 ac.overlays.palette = nil
446 ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}452 ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
447 ac.overlays.paletteHints = palette.ContextualHints{}453 ac.overlays.paletteHints = palette.ContextualHints{}
454+ ac.overlays.palettePreview = ""
448 ac.overlays.paletteFeedback = ""455 ac.overlays.paletteFeedback = ""
449 ac.overlays.palettePending = nil456 ac.overlays.palettePending = nil
450 }457 }
@@ -525,6 +532,20 @@ func (e paletteExec) CreateSession() error {
525 return nil532 return nil
526 }533 }
527 534
535+func (e paletteExec) CreateSessionNamed(name string) error {
536+ if e.effect == nil {
537+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
538+ }
539+ return e.d.createSessionAndSwitchForAttachment(e.effect.connectionToken(), name)
540+}
541+
542+func (e paletteExec) CreateEphemeralSession() error {
543+ if e.effect == nil {
544+ return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
545+ }
546+ return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect.connectionToken())
547+}
548+
528 func (e paletteExec) CloseTab() error {549 func (e paletteExec) CloseTab() error {
529 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})550 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
530 }551 }
@@ -651,6 +672,10 @@ func (e paletteExec) RenameSession() error {
651 return nil672 return nil
652 }673 }
653 674
675+func (e paletteExec) RenameSessionTo(name string) error {
676+ return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
677+}
678+
654 func (e paletteExec) RenameTab() error {679 func (e paletteExec) RenameTab() error {
655 tb := e.sess.tabForAttachment(e.ac)680 tb := e.sess.tabForAttachment(e.ac)
656 if e.ac == nil {681 if e.ac == nil {
@@ -675,6 +700,17 @@ func (e paletteExec) RenameTab() error {
675 return nil700 return nil
676 }701 }
677 702
703+func (e paletteExec) RenameTabTo(name string) error {
704+ tb := e.sess.tabForAttachment(e.ac)
705+ if e.ac == nil {
706+ tb = e.sess.firstTab()
707+ }
708+ if tb == nil {
709+ return nil
710+ }
711+ return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
712+}
713+
678 func (e paletteExec) OpenSessionPicker() error {714 func (e paletteExec) OpenSessionPicker() error {
679 if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {715 if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
680 return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)716 return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
M internal/usecase/daemon/palette_test.go +43 -2
@@ -708,6 +708,47 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
708708 require.NotContains(t, string(finalOutput.Data), "Create session")
709709 }
710710
711+func TestPaletteCNSArgumentCreatesNamedSessionWithoutPrompt(t *testing.T) {
712+ p1, release1 := newBlockingPTY(t)
713+ p2, release2 := newBlockingPTY(t)
714+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
715+ defer release1()
716+ defer release2()
717+ d.ptys = newFactorySeq(t, p2)
718+
719+ d.handleInput(sess, ac, []byte("\x1b "))
720+ awaitFrame(t, sends, ports.MsgOutput)
721+ d.handleInput(sess, ac, []byte("CNS scratch\r"))
722+ awaitFrame(t, sends, ports.MsgOutput)
723+
724+ require.False(t, ac.overlays.paletteActive())
725+ require.False(t, ac.overlays.promptActive())
726+ require.Equal(t, 2, sessionCount(d))
727+ newSess := ac.currentSession()
728+ require.Equal(t, "scratch", newSess.name)
729+ require.False(t, newSess.ephemeral)
730+}
731+
732+func TestPaletteCESCreatesAndSwitchesToEphemeralSession(t *testing.T) {
733+ p1, release1 := newBlockingPTY(t)
734+ p2, release2 := newBlockingPTY(t)
735+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
736+ defer release1()
737+ defer release2()
738+ d.ptys = newFactorySeq(t, p2)
739+
740+ d.handleInput(sess, ac, []byte("\x1b "))
741+ awaitFrame(t, sends, ports.MsgOutput)
742+ d.handleInput(sess, ac, []byte("CES\r"))
743+ awaitFrame(t, sends, ports.MsgOutput)
744+
745+ require.False(t, ac.overlays.paletteActive())
746+ require.Equal(t, 2, sessionCount(d))
747+ newSess := ac.currentSession()
748+ require.Equal(t, "0", newSess.name)
749+ require.True(t, newSess.ephemeral)
750+}
751+
711752 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
712753 d, sess, ac, sends, releases := newManualTabSession(t, 2)
713754 sess.mu.Lock()
@@ -774,12 +815,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
774815 for i, cmd := range commands {
775816 codes[i] = cmd.Code
776817 }
777- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BCK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
818+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BCK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
778819 }
779820
780821 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
781822 d := &Daemon{}
782- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "SSP", "VIS", "RNS", "RNT", "DET"}
823+ codes := []string{"CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "SSP", "VIS", "RNS", "RNT", "DET"}
783824
784825 var wg sync.WaitGroup
785826 for range 50 {
@@ -708,6 +708,47 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
708 require.NotContains(t, string(finalOutput.Data), "Create session")708 require.NotContains(t, string(finalOutput.Data), "Create session")
709 }709 }
710 710
711+func TestPaletteCNSArgumentCreatesNamedSessionWithoutPrompt(t *testing.T) {
712+ p1, release1 := newBlockingPTY(t)
713+ p2, release2 := newBlockingPTY(t)
714+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
715+ defer release1()
716+ defer release2()
717+ d.ptys = newFactorySeq(t, p2)
718+
719+ d.handleInput(sess, ac, []byte("\x1b "))
720+ awaitFrame(t, sends, ports.MsgOutput)
721+ d.handleInput(sess, ac, []byte("CNS scratch\r"))
722+ awaitFrame(t, sends, ports.MsgOutput)
723+
724+ require.False(t, ac.overlays.paletteActive())
725+ require.False(t, ac.overlays.promptActive())
726+ require.Equal(t, 2, sessionCount(d))
727+ newSess := ac.currentSession()
728+ require.Equal(t, "scratch", newSess.name)
729+ require.False(t, newSess.ephemeral)
730+}
731+
732+func TestPaletteCESCreatesAndSwitchesToEphemeralSession(t *testing.T) {
733+ p1, release1 := newBlockingPTY(t)
734+ p2, release2 := newBlockingPTY(t)
735+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
736+ defer release1()
737+ defer release2()
738+ d.ptys = newFactorySeq(t, p2)
739+
740+ d.handleInput(sess, ac, []byte("\x1b "))
741+ awaitFrame(t, sends, ports.MsgOutput)
742+ d.handleInput(sess, ac, []byte("CES\r"))
743+ awaitFrame(t, sends, ports.MsgOutput)
744+
745+ require.False(t, ac.overlays.paletteActive())
746+ require.Equal(t, 2, sessionCount(d))
747+ newSess := ac.currentSession()
748+ require.Equal(t, "0", newSess.name)
749+ require.True(t, newSess.ephemeral)
750+}
751+
711 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {752 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
712 d, sess, ac, sends, releases := newManualTabSession(t, 2)753 d, sess, ac, sends, releases := newManualTabSession(t, 2)
713 sess.mu.Lock()754 sess.mu.Lock()
@@ -774,12 +815,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
774 for i, cmd := range commands {815 for i, cmd := range commands {
775 codes[i] = cmd.Code816 codes[i] = cmd.Code
776 }817 }
777- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BCK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)818+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BCK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
778 }819 }
779 820
780 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {821 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
781 d := &Daemon{}822 d := &Daemon{}
782- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "SSP", "VIS", "RNS", "RNT", "DET"}823+ codes := []string{"CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "SSP", "VIS", "RNS", "RNT", "DET"}
783 824
784 var wg sync.WaitGroup825 var wg sync.WaitGroup
785 for range 50 {826 for range 50 {
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -267,7 +267,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
267267 guidance = snap.paletteHints.Feedback
268268 }
269269 o.paletteGuidance = snap.paletteFeedback
270- o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
270+ o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
271271 }
272272 if snap.promptActive && snap.promptModel != nil {
273273 modal := promptModalFor(snap.promptModel.Title())
@@ -267,7 +267,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
267 guidance = snap.paletteHints.Feedback267 guidance = snap.paletteHints.Feedback
268 }268 }
269 o.paletteGuidance = snap.paletteFeedback269 o.paletteGuidance = snap.paletteFeedback
270- o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})270+ o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
271 }271 }
272 if snap.promptActive && snap.promptModel != nil {272 if snap.promptActive && snap.promptModel != nil {
273 modal := promptModalFor(snap.promptModel.Title())273 modal := promptModalFor(snap.promptModel.Title())
M internal/usecase/daemon/session.go +75 -0
@@ -521,6 +521,81 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
521521 return nil
522522 }
523523
524+func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {
525+ sz := ac.sizeSnapshot()
526+ d.mu.Lock()
527+ if d.closing {
528+ d.mu.Unlock()
529+ return errors.New("daemon is shutting down")
530+ }
531+ from.mu.Lock()
532+ cwd, term, env := from.cwd, from.terminal, copyEnvironment(from.env)
533+ _, attached := from.attachments[ac]
534+ from.mu.Unlock()
535+ if !attached {
536+ d.mu.Unlock()
537+ return errors.New("client detached")
538+ }
539+ name := d.allocEphemeralNameLocked()
540+ newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, term, env)
541+ d.mu.Unlock()
542+ if err != nil {
543+ return err
544+ }
545+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
546+ source: from, target: newSess, next: ac,
547+ expectedTransport: ac.transportSnapshot(), ready: true,
548+ })
549+ if err != nil {
550+ _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
551+ return err
552+ }
553+ d.touchMRU(newSess)
554+ d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
555+ d.deferAttachmentTransitionCleanups(transition)
556+ d.firstPaintForTransition(transition.published)
557+ return nil
558+}
559+
560+func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentConnectionToken) error {
561+ if token.sess == nil || token.ac == nil || token.effect == nil {
562+ return errAttachmentTransition
563+ }
564+ token.effect.bindActionEnd(d, "create-ephemeral-session")
565+ token.effect.End()
566+
567+ var created *session
568+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
569+ source: token.sess, next: token.ac,
570+ expectedTransport: token.transport, sourceToken: &token, action: "create-ephemeral-session",
571+ copySourceEnvironment: true, ready: true,
572+ createTargetLocked: func() (*session, error) {
573+ if token.sess == nil || d.closing {
574+ return nil, errAttachmentTransition
575+ }
576+ source := token.sess
577+ source.mu.Lock()
578+ cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
579+ source.mu.Unlock()
580+ name := d.allocEphemeralNameLocked()
581+ var createErr error
582+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), term, env)
583+ return created, createErr
584+ },
585+ })
586+ if err != nil {
587+ if created != nil {
588+ _ = d.killSession(created, ports.ReasonSessionKilled, true)
589+ }
590+ return err
591+ }
592+ d.touchMRU(created)
593+ d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
594+ d.deferAttachmentTransitionCleanups(transition)
595+ d.firstPaintForTransition(transition.published)
596+ return nil
597+}
598+
524599 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
525600 return d.createTabForAttachment(sess, nil, sz)
526601 }
@@ -521,6 +521,81 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
521 return nil521 return nil
522 }522 }
523 523
524+func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClient) error {
525+ sz := ac.sizeSnapshot()
526+ d.mu.Lock()
527+ if d.closing {
528+ d.mu.Unlock()
529+ return errors.New("daemon is shutting down")
530+ }
531+ from.mu.Lock()
532+ cwd, term, env := from.cwd, from.terminal, copyEnvironment(from.env)
533+ _, attached := from.attachments[ac]
534+ from.mu.Unlock()
535+ if !attached {
536+ d.mu.Unlock()
537+ return errors.New("client detached")
538+ }
539+ name := d.allocEphemeralNameLocked()
540+ newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, term, env)
541+ d.mu.Unlock()
542+ if err != nil {
543+ return err
544+ }
545+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
546+ source: from, target: newSess, next: ac,
547+ expectedTransport: ac.transportSnapshot(), ready: true,
548+ })
549+ if err != nil {
550+ _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
551+ return err
552+ }
553+ d.touchMRU(newSess)
554+ d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
555+ d.deferAttachmentTransitionCleanups(transition)
556+ d.firstPaintForTransition(transition.published)
557+ return nil
558+}
559+
560+func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentConnectionToken) error {
561+ if token.sess == nil || token.ac == nil || token.effect == nil {
562+ return errAttachmentTransition
563+ }
564+ token.effect.bindActionEnd(d, "create-ephemeral-session")
565+ token.effect.End()
566+
567+ var created *session
568+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
569+ source: token.sess, next: token.ac,
570+ expectedTransport: token.transport, sourceToken: &token, action: "create-ephemeral-session",
571+ copySourceEnvironment: true, ready: true,
572+ createTargetLocked: func() (*session, error) {
573+ if token.sess == nil || d.closing {
574+ return nil, errAttachmentTransition
575+ }
576+ source := token.sess
577+ source.mu.Lock()
578+ cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
579+ source.mu.Unlock()
580+ name := d.allocEphemeralNameLocked()
581+ var createErr error
582+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), term, env)
583+ return created, createErr
584+ },
585+ })
586+ if err != nil {
587+ if created != nil {
588+ _ = d.killSession(created, ports.ReasonSessionKilled, true)
589+ }
590+ return err
591+ }
592+ d.touchMRU(created)
593+ d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
594+ d.deferAttachmentTransitionCleanups(transition)
595+ d.firstPaintForTransition(transition.published)
596+ return nil
597+}
598+
524 func (d *Daemon) createTab(sess *session, sz domain.Size) error {599 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
525 return d.createTabForAttachment(sess, nil, sz)600 return d.createTabForAttachment(sess, nil, sz)
526 }601 }
M internal/usecase/palette/action.go +29 -3
@@ -2,6 +2,7 @@ package palette
22
33 import (
44 "strings"
5+ "unicode"
56
67 "github.com/bnema/vev/internal/usecase/command"
78 )
@@ -15,7 +16,7 @@ type Action struct {
1516
1617 // ParseAction recognizes an exact effective command result token. It accepts
1718 // outer and repeated whitespace but never treats a prefix or concatenated token
18-// as a command. Static commands reject arguments.
19+// as a command. Commands with optional arguments accept zero or more arguments.
1920 func ParseAction(results []Result, input string) (Action, bool) {
2021 fields := strings.Fields(input)
2122 if len(fields) == 0 {
@@ -42,13 +43,38 @@ func ParseAction(results []Result, input string) (Action, bool) {
4243 return Action{}, false
4344 }
4445
45-// ArgumentCommand returns the exact argument-taking command whose token is
46+// ExactCommandResult returns a command whose code exactly matches input.
47+func ExactCommandResult(results []Result, input string) (Result, bool) {
48+ if strings.Fields(input) == nil || strings.ContainsAny(strings.TrimSpace(input), " \t\n\r") {
49+ return Result{}, false
50+ }
51+ for _, result := range results {
52+ cmd, ok := result.Command()
53+ if ok && strings.EqualFold(strings.TrimSpace(input), cmd.Code) {
54+ return result, true
55+ }
56+ }
57+ return Result{}, false
58+}
59+
60+// ArgumentCommand returns the exact argument-capable command whose token is
4661 // being typed, including the whitespace-before-argument state.
4762 func ArgumentCommand(results []Result, input string) (command.Command, bool) {
4863 _, cmd, ok := argumentResult(results, input)
4964 return cmd, ok
5065 }
5166
67+// Preview returns the pure preview text for an exact argument-capable command.
68+// It never consults mutable daemon state.
69+func Preview(cmd command.Command, input string) string {
70+ if cmd.Preview == nil {
71+ return ""
72+ }
73+ fields := strings.Fields(input)
74+ hasArgument := len(fields) > 1 || strings.TrimRightFunc(input, unicode.IsSpace) != input
75+ return cmd.Preview(append([]string(nil), fields[1:]...), hasArgument)
76+}
77+
5278 // argumentResult finds the exact argument-taking command and its source row in
5379 // one pass so callers can reuse the immutable result without another scan.
5480 func argumentResult(results []Result, input string) (Result, command.Command, bool) {
@@ -58,7 +84,7 @@ func argumentResult(results []Result, input string) (Result, command.Command, bo
5884 }
5985 for _, result := range results {
6086 cmd, ok := result.Command()
61- if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
87+ if ok && cmd.Arguments != command.ArgumentsNone && strings.EqualFold(fields[0], cmd.Code) {
6288 return result, cmd, true
6389 }
6490 }
@@ -2,6 +2,7 @@ package palette
2 2
3 import (3 import (
4 "strings"4 "strings"
5+ "unicode"
5 6
6 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
7 )8 )
@@ -15,7 +16,7 @@ type Action struct {
15 16
16 // ParseAction recognizes an exact effective command result token. It accepts17 // ParseAction recognizes an exact effective command result token. It accepts
17 // outer and repeated whitespace but never treats a prefix or concatenated token18 // outer and repeated whitespace but never treats a prefix or concatenated token
18-// as a command. Static commands reject arguments.19+// as a command. Commands with optional arguments accept zero or more arguments.
19 func ParseAction(results []Result, input string) (Action, bool) {20 func ParseAction(results []Result, input string) (Action, bool) {
20 fields := strings.Fields(input)21 fields := strings.Fields(input)
21 if len(fields) == 0 {22 if len(fields) == 0 {
@@ -42,13 +43,38 @@ func ParseAction(results []Result, input string) (Action, bool) {
42 return Action{}, false43 return Action{}, false
43 }44 }
44 45
45-// ArgumentCommand returns the exact argument-taking command whose token is46+// ExactCommandResult returns a command whose code exactly matches input.
47+func ExactCommandResult(results []Result, input string) (Result, bool) {
48+ if strings.Fields(input) == nil || strings.ContainsAny(strings.TrimSpace(input), " \t\n\r") {
49+ return Result{}, false
50+ }
51+ for _, result := range results {
52+ cmd, ok := result.Command()
53+ if ok && strings.EqualFold(strings.TrimSpace(input), cmd.Code) {
54+ return result, true
55+ }
56+ }
57+ return Result{}, false
58+}
59+
60+// ArgumentCommand returns the exact argument-capable command whose token is
46 // being typed, including the whitespace-before-argument state.61 // being typed, including the whitespace-before-argument state.
47 func ArgumentCommand(results []Result, input string) (command.Command, bool) {62 func ArgumentCommand(results []Result, input string) (command.Command, bool) {
48 _, cmd, ok := argumentResult(results, input)63 _, cmd, ok := argumentResult(results, input)
49 return cmd, ok64 return cmd, ok
50 }65 }
51 66
67+// Preview returns the pure preview text for an exact argument-capable command.
68+// It never consults mutable daemon state.
69+func Preview(cmd command.Command, input string) string {
70+ if cmd.Preview == nil {
71+ return ""
72+ }
73+ fields := strings.Fields(input)
74+ hasArgument := len(fields) > 1 || strings.TrimRightFunc(input, unicode.IsSpace) != input
75+ return cmd.Preview(append([]string(nil), fields[1:]...), hasArgument)
76+}
77+
52 // argumentResult finds the exact argument-taking command and its source row in78 // argumentResult finds the exact argument-taking command and its source row in
53 // one pass so callers can reuse the immutable result without another scan.79 // one pass so callers can reuse the immutable result without another scan.
54 func argumentResult(results []Result, input string) (Result, command.Command, bool) {80 func argumentResult(results []Result, input string) (Result, command.Command, bool) {
@@ -58,7 +84,7 @@ func argumentResult(results []Result, input string) (Result, command.Command, bo
58 }84 }
59 for _, result := range results {85 for _, result := range results {
60 cmd, ok := result.Command()86 cmd, ok := result.Command()
61- if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {87+ if ok && cmd.Arguments != command.ArgumentsNone && strings.EqualFold(fields[0], cmd.Code) {
62 return result, cmd, true88 return result, cmd, true
63 }89 }
64 }90 }
M internal/usecase/palette/action_test.go +3 -0
@@ -11,6 +11,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
1111 results := CommandResults([]command.Command{
1212 {Code: "BCK", Arguments: command.ArgumentsNone},
1313 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
14+ {Code: "CNS", Arguments: command.ArgumentsOptional},
1415 })
1516 tests := []struct {
1617 name string
@@ -25,6 +26,8 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
2526 {name: "concatenated token rejected", input: "JRS2", ok: false},
2627 {name: "static arguments rejected", input: "BCK 2", ok: false},
2728 {name: "missing required argument rejected", input: "JRS", ok: false},
29+ {name: "optional argument omitted", input: "CNS", ok: true, code: "CNS"},
30+ {name: "optional argument provided", input: "CNS toto", ok: true, code: "CNS", args: []string{"toto"}},
2831 }
2932 for _, tt := range tests {
3033 t.Run(tt.name, func(t *testing.T) {
@@ -11,6 +11,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
11 results := CommandResults([]command.Command{11 results := CommandResults([]command.Command{
12 {Code: "BCK", Arguments: command.ArgumentsNone},12 {Code: "BCK", Arguments: command.ArgumentsNone},
13 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},13 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
14+ {Code: "CNS", Arguments: command.ArgumentsOptional},
14 })15 })
15 tests := []struct {16 tests := []struct {
16 name string17 name string
@@ -25,6 +26,8 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
25 {name: "concatenated token rejected", input: "JRS2", ok: false},26 {name: "concatenated token rejected", input: "JRS2", ok: false},
26 {name: "static arguments rejected", input: "BCK 2", ok: false},27 {name: "static arguments rejected", input: "BCK 2", ok: false},
27 {name: "missing required argument rejected", input: "JRS", ok: false},28 {name: "missing required argument rejected", input: "JRS", ok: false},
29+ {name: "optional argument omitted", input: "CNS", ok: true, code: "CNS"},
30+ {name: "optional argument provided", input: "CNS toto", ok: true, code: "CNS", args: []string{"toto"}},
28 }31 }
29 for _, tt := range tests {32 for _, tt := range tests {
30 t.Run(tt.name, func(t *testing.T) {33 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/palette/model.go +16 -8
@@ -26,6 +26,9 @@ type RenderStyles struct {
2626 type RenderOptions struct {
2727 Styles RenderStyles
2828 Guidance string
29+ // Preview is the current exact command's action description. It replaces
30+ // the selected row's static description unless Feedback is present.
31+ Preview string
2932 // Feedback is interaction-scoped daemon feedback. It replaces the selected
3033 // row's contextual detail without adding a searchable result or row.
3134 Feedback string
@@ -120,7 +123,7 @@ func (m *Model) CompleteSelected() bool {
120123
121124 query := m.Query()
122125 completed := cmd.Code
123- if cmd.Arguments == command.ArgumentsRequired {
126+ if cmd.Arguments != command.ArgumentsNone {
124127 token, args, hasSeparator := completionParts(query)
125128 if token == cmd.Code && hasSeparator && args != "" {
126129 return false
@@ -180,13 +183,16 @@ func (m *Model) Matches() []Match {
180183 func (m *Model) refresh() {
181184 query := m.input.Value()
182185 m.matches = Fuzzy(m.results, query)
183- // Once an argument-taking static token is exact, retain its row while its
184- // arguments make ordinary fuzzy matching inapplicable.
185- if result, _, ok := argumentResult(m.results, query); ok {
186+ // Keep an exact command row selected over fuzzy session matches.
187+ if result, ok := ExactCommandResult(m.results, query); ok {
188+ m.prependMatch(result)
189+ // Once an argument-taking static token is exact, retain its row while its
190+ // arguments make ordinary fuzzy matching inapplicable.
191+ } else if result, _, ok := argumentResult(m.results, query); ok {
186192 m.prependMatch(result)
187193 } else if len(m.matches) == 0 {
188194 // Arguments are not part of static command matching. When they would
189- // otherwise clear the list, keep required-argument candidates matched by
195+ // otherwise clear the list, keep argument-capable candidates matched by
190196 // the partial first token so Tab can complete them.
191197 token, _, hasSeparator := completionParts(query)
192198 if hasSeparator {
@@ -200,7 +206,7 @@ func requiredArgumentResults(results []Result) []Result {
200206 commands := make([]Result, 0, len(results))
201207 for _, result := range results {
202208 cmd, ok := result.Command()
203- if ok && cmd.Arguments == command.ArgumentsRequired {
209+ if ok && cmd.Arguments != command.ArgumentsNone {
204210 commands = append(commands, result)
205211 }
206212 }
@@ -290,7 +296,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
290296 }
291297 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
292298 if cmd, ok := match.Result.Command(); ok {
293- m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
299+ m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected)
294300 continue
295301 }
296302 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
@@ -298,7 +304,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
298304 return frame
299305 }
300306
301-func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
307+func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, preview, feedback string, selected bool) {
302308 x, nextHighlight := 0, 0
303309 for _, r := range cmd.Code {
304310 cellStyle := style
@@ -320,6 +326,8 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
320326 description := cmd.Desc
321327 if feedback != "" && selected {
322328 description = feedback
329+ } else if preview != "" && selected {
330+ description = preview
323331 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
324332 description = guidance
325333 }
@@ -26,6 +26,9 @@ type RenderStyles struct {
26 type RenderOptions struct {26 type RenderOptions struct {
27 Styles RenderStyles27 Styles RenderStyles
28 Guidance string28 Guidance string
29+ // Preview is the current exact command's action description. It replaces
30+ // the selected row's static description unless Feedback is present.
31+ Preview string
29 // Feedback is interaction-scoped daemon feedback. It replaces the selected32 // Feedback is interaction-scoped daemon feedback. It replaces the selected
30 // row's contextual detail without adding a searchable result or row.33 // row's contextual detail without adding a searchable result or row.
31 Feedback string34 Feedback string
@@ -120,7 +123,7 @@ func (m *Model) CompleteSelected() bool {
120 123
121 query := m.Query()124 query := m.Query()
122 completed := cmd.Code125 completed := cmd.Code
123- if cmd.Arguments == command.ArgumentsRequired {126+ if cmd.Arguments != command.ArgumentsNone {
124 token, args, hasSeparator := completionParts(query)127 token, args, hasSeparator := completionParts(query)
125 if token == cmd.Code && hasSeparator && args != "" {128 if token == cmd.Code && hasSeparator && args != "" {
126 return false129 return false
@@ -180,13 +183,16 @@ func (m *Model) Matches() []Match {
180 func (m *Model) refresh() {183 func (m *Model) refresh() {
181 query := m.input.Value()184 query := m.input.Value()
182 m.matches = Fuzzy(m.results, query)185 m.matches = Fuzzy(m.results, query)
183- // Once an argument-taking static token is exact, retain its row while its186+ // Keep an exact command row selected over fuzzy session matches.
184- // arguments make ordinary fuzzy matching inapplicable.187+ if result, ok := ExactCommandResult(m.results, query); ok {
185- if result, _, ok := argumentResult(m.results, query); ok {188+ m.prependMatch(result)
189+ // Once an argument-taking static token is exact, retain its row while its
190+ // arguments make ordinary fuzzy matching inapplicable.
191+ } else if result, _, ok := argumentResult(m.results, query); ok {
186 m.prependMatch(result)192 m.prependMatch(result)
187 } else if len(m.matches) == 0 {193 } else if len(m.matches) == 0 {
188 // Arguments are not part of static command matching. When they would194 // Arguments are not part of static command matching. When they would
189- // otherwise clear the list, keep required-argument candidates matched by195+ // otherwise clear the list, keep argument-capable candidates matched by
190 // the partial first token so Tab can complete them.196 // the partial first token so Tab can complete them.
191 token, _, hasSeparator := completionParts(query)197 token, _, hasSeparator := completionParts(query)
192 if hasSeparator {198 if hasSeparator {
@@ -200,7 +206,7 @@ func requiredArgumentResults(results []Result) []Result {
200 commands := make([]Result, 0, len(results))206 commands := make([]Result, 0, len(results))
201 for _, result := range results {207 for _, result := range results {
202 cmd, ok := result.Command()208 cmd, ok := result.Command()
203- if ok && cmd.Arguments == command.ArgumentsRequired {209+ if ok && cmd.Arguments != command.ArgumentsNone {
204 commands = append(commands, result)210 commands = append(commands, result)
205 }211 }
206 }212 }
@@ -290,7 +296,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
290 }296 }
291 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})297 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
292 if cmd, ok := match.Result.Command(); ok {298 if cmd, ok := match.Result.Command(); ok {
293- m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)299+ m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, idx == m.selected)
294 continue300 continue
295 }301 }
296 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)302 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
@@ -298,7 +304,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
298 return frame304 return frame
299 }305 }
300 306
301-func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {307+func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, preview, feedback string, selected bool) {
302 x, nextHighlight := 0, 0308 x, nextHighlight := 0, 0
303 for _, r := range cmd.Code {309 for _, r := range cmd.Code {
304 cellStyle := style310 cellStyle := style
@@ -320,6 +326,8 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
320 description := cmd.Desc326 description := cmd.Desc
321 if feedback != "" && selected {327 if feedback != "" && selected {
322 description = feedback328 description = feedback
329+ } else if preview != "" && selected {
330+ description = preview
323 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {331 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
324 description = guidance332 description = guidance
325 }333 }
A scripts/dev/Dockerfile +22 -0
@@ -0,0 +1,22 @@
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 -trimpath -o /out/vev .
8+
9+FROM debian:bookworm-slim
10+RUN apt-get update \
11+ && apt-get install --yes --no-install-recommends bash ca-certificates \
12+ && rm -rf /var/lib/apt/lists/* \
13+ && useradd --create-home --uid 1000 --shell /bin/bash vev
14+COPY --from=build /out/vev /usr/local/bin/vev
15+USER vev
16+WORKDIR /home/vev
17+ENV HOME=/home/vev \
18+ SHELL=/bin/bash \
19+ TERM=xterm-256color \
20+ COLORTERM=truecolor \
21+ XDG_STATE_HOME=/home/vev/.local/state
22+ENTRYPOINT ["/usr/local/bin/vev"]
@@ -0,0 +1,22 @@
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 -trimpath -o /out/vev .
8+
9+FROM debian:bookworm-slim
10+RUN apt-get update \
11+ && apt-get install --yes --no-install-recommends bash ca-certificates \
12+ && rm -rf /var/lib/apt/lists/* \
13+ && useradd --create-home --uid 1000 --shell /bin/bash vev
14+COPY --from=build /out/vev /usr/local/bin/vev
15+USER vev
16+WORKDIR /home/vev
17+ENV HOME=/home/vev \
18+ SHELL=/bin/bash \
19+ TERM=xterm-256color \
20+ COLORTERM=truecolor \
21+ XDG_STATE_HOME=/home/vev/.local/state
22+ENTRYPOINT ["/usr/local/bin/vev"]

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