feat(palette): add argument previews and ephemeral sessions
- commit
- 3795579c930c66a70ee8233a94f0650bd3fd19b4
- author
- brice · · 2026-08-11 14:23 UTC
- parent
- bb3ed31
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 39 | 39 | type Context interface { | |
| 40 | 40 | CreateTab() error | |
| 41 | 41 | CreateSession() error | |
| 42 | + | CreateSessionNamed(name string) error | |
| 43 | + | CreateEphemeralSession() error | |
| 42 | 44 | CloseTab() error | |
| 43 | 45 | SplitRight() error | |
| 44 | 46 | SplitLeft() error | |
| @@ -68,7 +70,9 @@ type Context interface { | |||
| 68 | 70 | Detach() error | |
| 69 | 71 | EnterVisualMode() error | |
| 70 | 72 | RenameSession() error | |
| 73 | + | RenameSessionTo(name string) error | |
| 71 | 74 | RenameTab() error | |
| 75 | + | RenameTabTo(name string) error | |
| 72 | 76 | OpenSessionPicker() error | |
| 73 | 77 | OpenNotifications() error | |
| 74 | 78 | YankLastNotification() error | |
| @@ -80,6 +84,7 @@ type Arguments uint8 | |||
| 80 | 84 | ||
| 81 | 85 | const ( | |
| 82 | 86 | ArgumentsNone Arguments = iota | |
| 87 | + | ArgumentsOptional | |
| 83 | 88 | ArgumentsRequired | |
| 84 | 89 | ) | |
| 85 | 90 | ||
| @@ -163,11 +168,14 @@ type Command struct { | |||
| 163 | 168 | Slug, Code, Name, Desc string | |
| 164 | 169 | Usage string | |
| 165 | 170 | 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) | |
| 173 | 181 | } | |
| @@ -39,6 +39,8 @@ func ParsePositiveDecimal(args []string) (int, error) { | |||||
| 39 | type Context interface { | 39 | type Context interface { | ||
| 40 | CreateTab() error | 40 | CreateTab() error | ||
| 41 | CreateSession() error | 41 | CreateSession() error | ||
| 42 | + | CreateSessionNamed(name string) error | |||
| 43 | + | CreateEphemeralSession() error | |||
| 42 | CloseTab() error | 44 | CloseTab() error | ||
| 43 | SplitRight() error | 45 | SplitRight() error | ||
| 44 | SplitLeft() error | 46 | SplitLeft() error | ||
| @@ -68,7 +70,9 @@ type Context interface { | |||||
| 68 | Detach() error | 70 | Detach() error | ||
| 69 | EnterVisualMode() error | 71 | EnterVisualMode() error | ||
| 70 | RenameSession() error | 72 | RenameSession() error | ||
| 73 | + | RenameSessionTo(name string) error | |||
| 71 | RenameTab() error | 74 | RenameTab() error | ||
| 75 | + | RenameTabTo(name string) error | |||
| 72 | OpenSessionPicker() error | 76 | OpenSessionPicker() error | ||
| 73 | OpenNotifications() error | 77 | OpenNotifications() error | ||
| 74 | YankLastNotification() error | 78 | YankLastNotification() error | ||
| @@ -80,6 +84,7 @@ type Arguments uint8 | |||||
| 80 | 84 | ||||
| 81 | const ( | 85 | const ( | ||
| 82 | ArgumentsNone Arguments = iota | 86 | ArgumentsNone Arguments = iota | ||
| 87 | + | ArgumentsOptional | |||
| 83 | ArgumentsRequired | 88 | ArgumentsRequired | ||
| 84 | ) | 89 | ) | ||
| 85 | 90 | ||||
| @@ -163,11 +168,14 @@ type Command struct { | |||||
| 163 | Slug, Code, Name, Desc string | 168 | Slug, Code, Name, Desc string | ||
| 164 | Usage string | 169 | Usage string | ||
| 165 | Arguments Arguments | 170 | Arguments Arguments | ||
| 166 | - | ContextHint ContextHint | 171 | + | // Preview describes the selected palette command while its exact token is |
| 167 | - | Scriptable bool | 172 | + | // being entered. hasArgument is true after whitespace following the token. |
| 168 | - | PaletteVisible bool | 173 | + | Preview func(args []string, hasArgument bool) string |
| 169 | - | Target TargetKind | 174 | + | ContextHint ContextHint |
| 170 | - | Scope CommandScope | 175 | + | Scriptable bool |
| 171 | - | Run func(Context, []string) error | 176 | + | 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 | |||
| 255 | 255 | 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 | 302 | // CreateSession provides a mock function for the type MockContext | |
| 259 | 303 | func (_mock *MockContext) CreateSession() error { | |
| 260 | 304 | ret := _mock.Called() | |
| @@ -299,6 +343,57 @@ func (_c *MockContext_CreateSession_Call) RunAndReturn(run func() error) *MockCo | |||
| 299 | 343 | 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 | 397 | // CreateTab provides a mock function for the type MockContext | |
| 303 | 398 | func (_mock *MockContext) CreateTab() error { | |
| 304 | 399 | ret := _mock.Called() | |
| @@ -1142,6 +1237,57 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo | |||
| 1142 | 1237 | 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 | 1291 | // RenameTab provides a mock function for the type MockContext | |
| 1146 | 1292 | func (_mock *MockContext) RenameTab() error { | |
| 1147 | 1293 | ret := _mock.Called() | |
| @@ -1186,6 +1332,57 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex | |||
| 1186 | 1332 | 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 | 1386 | // ShrinkPaneHeight provides a mock function for the type MockContext | |
| 1190 | 1387 | func (_mock *MockContext) ShrinkPaneHeight() error { | |
| 1191 | 1388 | ret := _mock.Called() | |
| @@ -255,6 +255,50 @@ func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() erro | |||||
| 255 | return _c | 255 | 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 MockContext | 302 | // 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 _c | 343 | 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 MockContext | 397 | // 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 _c | 1237 | 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 MockContext | 1291 | // 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 _c | 1332 | 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 MockContext | 1386 | // 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" | |||
| 6 | 6 | func Registry() []Command { | |
| 7 | 7 | commands := []Command{ | |
| 8 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 38 | rank, err := ParsePositiveDecimal(args) | |
| 38 | 39 | if err != nil { | |
| @@ -42,10 +43,10 @@ func Registry() []Command { | |||
| 42 | 43 | }}, | |
| 43 | 44 | paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }), | |
| 44 | 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 | 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 | - | 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) }), | |
| 49 | 50 | paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }), | |
| 50 | 51 | toastCommand(), | |
| 51 | 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 | - | 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 | + | } | |
| 75 | 102 | cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 76 | 103 | if len(args) != 1 || args[0] == "" { | |
| 77 | 104 | 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{}, ErrInvalidArguments | 104 | return ControlResult{}, ErrInvalidArguments | ||
M internal/usecase/command/registry_test.go +1 -1
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 11 | 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 | 14 | if len(commands) != len(wantCodes) { | |
| 15 | 15 | 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 { | |||
| 46 | 46 | paletteRouteSnapshot ports.RecentRouteSnapshot | |
| 47 | 47 | paletteGeneration uint64 | |
| 48 | 48 | paletteHints palette.ContextualHints | |
| 49 | + | palettePreview string | |
| 49 | 50 | paletteFeedback string | |
| 50 | 51 | palettePending []byte | |
| 51 | 52 | ||
| @@ -306,6 +307,7 @@ type overlayRenderSnapshot struct { | |||
| 306 | 307 | // paletteHints is a copy captured under paletteMu. Rendering must use this | |
| 307 | 308 | // immutable interaction snapshot rather than consult live session state. | |
| 308 | 309 | paletteHints *palette.ContextualHints | |
| 310 | + | palettePreview string | |
| 309 | 311 | paletteFeedback string | |
| 310 | 312 | paletteRouteSnapshot ports.RecentRouteSnapshot | |
| 311 | 313 | paletteLocked bool | |
| @@ -378,6 +380,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 378 | 380 | hints := rt.paletteHints | |
| 379 | 381 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | |
| 380 | 382 | snap.paletteHints = &hints | |
| 383 | + | snap.palettePreview = rt.palettePreview | |
| 381 | 384 | snap.paletteFeedback = rt.paletteFeedback | |
| 382 | 385 | snap.paletteRouteSnapshot = rt.paletteRouteSnapshot | |
| 383 | 386 | snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...) | |
| @@ -46,6 +46,7 @@ type overlayRuntime struct { | |||||
| 46 | paletteRouteSnapshot ports.RecentRouteSnapshot | 46 | paletteRouteSnapshot ports.RecentRouteSnapshot | ||
| 47 | paletteGeneration uint64 | 47 | paletteGeneration uint64 | ||
| 48 | paletteHints palette.ContextualHints | 48 | paletteHints palette.ContextualHints | ||
| 49 | + | palettePreview string | |||
| 49 | paletteFeedback string | 50 | paletteFeedback string | ||
| 50 | palettePending []byte | 51 | palettePending []byte | ||
| 51 | 52 | ||||
| @@ -306,6 +307,7 @@ type overlayRenderSnapshot struct { | |||||
| 306 | // paletteHints is a copy captured under paletteMu. Rendering must use this | 307 | // 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.ContextualHints | 309 | paletteHints *palette.ContextualHints | ||
| 310 | + | palettePreview string | |||
| 309 | paletteFeedback string | 311 | paletteFeedback string | ||
| 310 | paletteRouteSnapshot ports.RecentRouteSnapshot | 312 | paletteRouteSnapshot ports.RecentRouteSnapshot | ||
| 311 | paletteLocked bool | 313 | paletteLocked bool | ||
| @@ -378,6 +380,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 378 | hints := rt.paletteHints | 380 | hints := rt.paletteHints | ||
| 379 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | 381 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | ||
| 380 | snap.paletteHints = &hints | 382 | snap.paletteHints = &hints | ||
| 383 | + | snap.palettePreview = rt.palettePreview | |||
| 381 | snap.paletteFeedback = rt.paletteFeedback | 384 | snap.paletteFeedback = rt.paletteFeedback | ||
| 382 | snap.paletteRouteSnapshot = rt.paletteRouteSnapshot | 385 | 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) { | |||
| 48 | 48 | ac.overlays.palette = palette.New(results) | |
| 49 | 49 | ac.overlays.paletteRouteSnapshot = routeSnapshot | |
| 50 | 50 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 51 | + | ac.overlays.palettePreview = "" | |
| 51 | 52 | ac.overlays.paletteFeedback = "" | |
| 52 | 53 | ac.overlays.palettePending = nil | |
| 53 | 54 | ac.overlays.paletteMu.Unlock() | |
| @@ -266,15 +267,16 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 266 | 267 | rawQuery = ac.overlays.palette.Query() | |
| 267 | 268 | if selectedCommand, ok := selected.Command(); ok { | |
| 268 | 269 | 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 { | |
| 272 | 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 | 275 | changed = true | |
| 275 | 276 | return | |
| 276 | 277 | } | |
| 277 | - | args = action.Args | |
| 278 | + | } | |
| 279 | + | if cmd.ContextHint == command.ContextHintRecentSessions { | |
| 278 | 280 | routeSnapshot = ac.overlays.paletteRouteSnapshot | |
| 279 | 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 | 297 | if changed { | |
| 296 | 298 | ac.overlays.paletteFeedback = "" | |
| 299 | + | ac.overlays.palettePreview = "" | |
| 297 | 300 | active, ok := ac.overlays.palette.ArgumentCommand() | |
| 301 | + | if ok { | |
| 302 | + | ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query()) | |
| 303 | + | } | |
| 298 | 304 | if ok && active.Slug == "jump-recent-session" { | |
| 299 | 305 | args := paletteArgs(ac.overlays.palette.Query(), active) | |
| 300 | 306 | if ac.overlays.paletteRouteSnapshot.Generation != 0 { | |
| @@ -445,6 +451,7 @@ func (ac *attachedClient) clearPaletteLocked() { | |||
| 445 | 451 | ac.overlays.palette = nil | |
| 446 | 452 | ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{} | |
| 447 | 453 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 454 | + | ac.overlays.palettePreview = "" | |
| 448 | 455 | ac.overlays.paletteFeedback = "" | |
| 449 | 456 | ac.overlays.palettePending = nil | |
| 450 | 457 | } | |
| @@ -525,6 +532,20 @@ func (e paletteExec) CreateSession() error { | |||
| 525 | 532 | 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 | 549 | func (e paletteExec) CloseTab() error { | |
| 529 | 550 | return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) | |
| 530 | 551 | } | |
| @@ -651,6 +672,10 @@ func (e paletteExec) RenameSession() error { | |||
| 651 | 672 | 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 | 679 | func (e paletteExec) RenameTab() error { | |
| 655 | 680 | tb := e.sess.tabForAttachment(e.ac) | |
| 656 | 681 | if e.ac == nil { | |
| @@ -675,6 +700,17 @@ func (e paletteExec) RenameTab() error { | |||
| 675 | 700 | 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 | 714 | func (e paletteExec) OpenSessionPicker() error { | |
| 679 | 715 | if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil { | |
| 680 | 716 | 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 = routeSnapshot | 49 | 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 = nil | 53 | 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 = selectedCommand | 269 | cmd = selectedCommand | ||
| 269 | - | // JRS is contextual: fuzzy selection cannot turn an unrelated | 270 | + | 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 = true | 275 | changed = true | ||
| 275 | return | 276 | return | ||
| 276 | } | 277 | } | ||
| 277 | - | args = action.Args | 278 | + | } |
| 279 | + | if cmd.ContextHint == command.ContextHintRecentSessions { | |||
| 278 | routeSnapshot = ac.overlays.paletteRouteSnapshot | 280 | 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 = nil | 451 | 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 = nil | 456 | ac.overlays.palettePending = nil | ||
| 450 | } | 457 | } | ||
| @@ -525,6 +532,20 @@ func (e paletteExec) CreateSession() error { | |||||
| 525 | return nil | 532 | 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 nil | 672 | 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 nil | 700 | 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) { | |||
| 708 | 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 | 752 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |
| 712 | 753 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 713 | 754 | sess.mu.Lock() | |
| @@ -774,12 +815,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 774 | 815 | for i, cmd := range commands { | |
| 775 | 816 | 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 | 821 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 781 | 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 | 825 | var wg sync.WaitGroup | |
| 785 | 826 | 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.Code | 816 | 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.WaitGroup | 825 | 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 | |||
| 267 | 267 | guidance = snap.paletteHints.Feedback | |
| 268 | 268 | } | |
| 269 | 269 | 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 | 272 | if snap.promptActive && snap.promptModel != nil { | |
| 273 | 273 | modal := promptModalFor(snap.promptModel.Title()) | |
| @@ -267,7 +267,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 267 | guidance = snap.paletteHints.Feedback | 267 | guidance = snap.paletteHints.Feedback | ||
| 268 | } | 268 | } | ||
| 269 | o.paletteGuidance = snap.paletteFeedback | 269 | 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 | |||
| 521 | 521 | 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 | 599 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 525 | 600 | return d.createTabForAttachment(sess, nil, sz) | |
| 526 | 601 | } | |
| @@ -521,6 +521,81 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||||
| 521 | return nil | 521 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | + | "unicode" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | 8 | ) | |
| @@ -15,7 +16,7 @@ type Action struct { | |||
| 15 | 16 | ||
| 16 | 17 | // ParseAction recognizes an exact effective command result token. It accepts | |
| 17 | 18 | // 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 | 20 | func ParseAction(results []Result, input string) (Action, bool) { | |
| 20 | 21 | fields := strings.Fields(input) | |
| 21 | 22 | if len(fields) == 0 { | |
| @@ -42,13 +43,38 @@ func ParseAction(results []Result, input string) (Action, bool) { | |||
| 42 | 43 | return Action{}, false | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 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 | |
| 46 | 61 | // being typed, including the whitespace-before-argument state. | |
| 47 | 62 | func ArgumentCommand(results []Result, input string) (command.Command, bool) { | |
| 48 | 63 | _, cmd, ok := argumentResult(results, input) | |
| 49 | 64 | 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 | 78 | // argumentResult finds the exact argument-taking command and its source row in | |
| 53 | 79 | // one pass so callers can reuse the immutable result without another scan. | |
| 54 | 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 | 85 | for _, result := range results { | |
| 60 | 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 | 88 | return result, cmd, true | |
| 63 | 89 | } | |
| 64 | 90 | } | |
| @@ -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 accepts | 17 | // ParseAction recognizes an exact effective command result token. It accepts | ||
| 17 | // outer and repeated whitespace but never treats a prefix or concatenated token | 18 | // 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{}, false | 43 | return Action{}, false | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 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 | |||
| 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, ok | 64 | 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 in | 78 | // 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, true | 88 | 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) { | |||
| 11 | 11 | results := CommandResults([]command.Command{ | |
| 12 | 12 | {Code: "BCK", Arguments: command.ArgumentsNone}, | |
| 13 | 13 | {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions}, | |
| 14 | + | {Code: "CNS", Arguments: command.ArgumentsOptional}, | |
| 14 | 15 | }) | |
| 15 | 16 | tests := []struct { | |
| 16 | 17 | name string | |
| @@ -25,6 +26,8 @@ func TestParseActionExactEffectiveToken(t *testing.T) { | |||
| 25 | 26 | {name: "concatenated token rejected", input: "JRS2", ok: false}, | |
| 26 | 27 | {name: "static arguments rejected", input: "BCK 2", ok: false}, | |
| 27 | 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 | 32 | for _, tt := range tests { | |
| 30 | 33 | 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 string | 17 | 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 { | |||
| 26 | 26 | type RenderOptions struct { | |
| 27 | 27 | Styles RenderStyles | |
| 28 | 28 | 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 | 32 | // Feedback is interaction-scoped daemon feedback. It replaces the selected | |
| 30 | 33 | // row's contextual detail without adding a searchable result or row. | |
| 31 | 34 | Feedback string | |
| @@ -120,7 +123,7 @@ func (m *Model) CompleteSelected() bool { | |||
| 120 | 123 | ||
| 121 | 124 | query := m.Query() | |
| 122 | 125 | completed := cmd.Code | |
| 123 | - | if cmd.Arguments == command.ArgumentsRequired { | |
| 126 | + | if cmd.Arguments != command.ArgumentsNone { | |
| 124 | 127 | token, args, hasSeparator := completionParts(query) | |
| 125 | 128 | if token == cmd.Code && hasSeparator && args != "" { | |
| 126 | 129 | return false | |
| @@ -180,13 +183,16 @@ func (m *Model) Matches() []Match { | |||
| 180 | 183 | func (m *Model) refresh() { | |
| 181 | 184 | query := m.input.Value() | |
| 182 | 185 | 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 { | |
| 186 | 192 | m.prependMatch(result) | |
| 187 | 193 | } else if len(m.matches) == 0 { | |
| 188 | 194 | // 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 | |
| 190 | 196 | // the partial first token so Tab can complete them. | |
| 191 | 197 | token, _, hasSeparator := completionParts(query) | |
| 192 | 198 | if hasSeparator { | |
| @@ -200,7 +206,7 @@ func requiredArgumentResults(results []Result) []Result { | |||
| 200 | 206 | commands := make([]Result, 0, len(results)) | |
| 201 | 207 | for _, result := range results { | |
| 202 | 208 | cmd, ok := result.Command() | |
| 203 | - | if ok && cmd.Arguments == command.ArgumentsRequired { | |
| 209 | + | if ok && cmd.Arguments != command.ArgumentsNone { | |
| 204 | 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 | 297 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 292 | 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 | 300 | continue | |
| 295 | 301 | } | |
| 296 | 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 | 304 | 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 | 308 | x, nextHighlight := 0, 0 | |
| 303 | 309 | for _, r := range cmd.Code { | |
| 304 | 310 | cellStyle := style | |
| @@ -320,6 +326,8 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des | |||
| 320 | 326 | description := cmd.Desc | |
| 321 | 327 | if feedback != "" && selected { | |
| 322 | 328 | description = feedback | |
| 329 | + | } else if preview != "" && selected { | |
| 330 | + | description = preview | |
| 323 | 331 | } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code { | |
| 324 | 332 | description = guidance | |
| 325 | 333 | } | |
| @@ -26,6 +26,9 @@ type RenderStyles struct { | |||||
| 26 | type RenderOptions struct { | 26 | type RenderOptions struct { | ||
| 27 | Styles RenderStyles | 27 | Styles RenderStyles | ||
| 28 | Guidance string | 28 | 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 selected | 32 | // 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 string | 34 | Feedback string | ||
| @@ -120,7 +123,7 @@ func (m *Model) CompleteSelected() bool { | |||||
| 120 | 123 | ||||
| 121 | query := m.Query() | 124 | query := m.Query() | ||
| 122 | completed := cmd.Code | 125 | 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 false | 129 | 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 its | 186 | + | // 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 would | 194 | // 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 |
| 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 | continue | 300 | 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 frame | 304 | 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, 0 | 308 | x, nextHighlight := 0, 0 | ||
| 303 | for _, r := range cmd.Code { | 309 | for _, r := range cmd.Code { | ||
| 304 | cellStyle := style | 310 | cellStyle := style | ||
| @@ -320,6 +326,8 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des | |||||
| 320 | description := cmd.Desc | 326 | description := cmd.Desc | ||
| 321 | if feedback != "" && selected { | 327 | if feedback != "" && selected { | ||
| 322 | description = feedback | 328 | 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 = guidance | 332 | 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"] | |||