ace-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,597 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Testing
6
+ # THE acceptance bar for any ace-runtime adapter (shared-examples
7
+ # pattern). Include AdapterContract in a Minitest::Test class and
8
+ # provide two hooks:
9
+ #
10
+ # def build_fixture = Testing::ScriptedRuntime.new
11
+ # def build_adapter(fixture) = adapter wired to the fixture
12
+ #
13
+ # Then also include exactly one send matrix module matching the
14
+ # profile the adapter declares via #send_profile:
15
+ # AdapterContract::PlainPaneSendMatrix or
16
+ # AdapterContract::AgentAwareSendMatrix.
17
+ #
18
+ # The adapter must implement the full published intent API over
19
+ # the fixture's native ops and translate fixture signals at its
20
+ # boundary (FixtureUnavailable -> RuntimeUnavailableError,
21
+ # FixtureStall -> SendStalledError, FixtureTargetMissing ->
22
+ # TargetNotFoundError).
23
+ module AdapterContract
24
+ WAIT_TIMEOUT = 0.05
25
+
26
+ def setup
27
+ super
28
+ @contract_fixture = build_fixture
29
+ @contract_adapter = build_adapter(@contract_fixture)
30
+ @contract_pane = nil
31
+ @contract_setup_op_count = nil
32
+ end
33
+
34
+ attr_reader :contract_fixture, :contract_adapter
35
+
36
+ def fixture
37
+ @contract_fixture
38
+ end
39
+
40
+ def adapter
41
+ @contract_adapter
42
+ end
43
+
44
+ # --- identity and context ---
45
+
46
+ def test_context_reports_scripted_environment
47
+ fixture.set_context(in_runtime: true, session: "main", window: "work", pane: "%1")
48
+
49
+ context = adapter.context
50
+
51
+ assert_equal true, context[:in_runtime]
52
+ assert_equal "main", context[:session]
53
+ assert_equal "work", context[:window]
54
+ assert_equal "%1", context[:pane]
55
+ end
56
+
57
+ def test_context_reports_absence_outside_runtime
58
+ fixture.set_context(in_runtime: false, session: nil, window: nil, pane: nil)
59
+
60
+ context = adapter.context
61
+
62
+ refute context[:in_runtime]
63
+ assert_nil context[:session]
64
+ assert_nil context[:window]
65
+ assert_nil context[:pane]
66
+ end
67
+
68
+ # --- ensure_window ---
69
+
70
+ def test_ensure_window_creates_window_with_sanitized_name
71
+ handle = adapter.ensure_window(name: "my work!", root: "/tmp/work")
72
+
73
+ assert_instance_of String, handle
74
+ refute handle.strip.empty?, "window handle must be opaque but present"
75
+ create = fixture.calls.find { |call| call[:op] == :create_window }
76
+ assert create, "expected a create_window transport call"
77
+ assert_equal "my-work", create[:name]
78
+ assert_equal "/tmp/work", create[:root]
79
+ end
80
+
81
+ def test_ensure_window_is_idempotent_by_name
82
+ first = adapter.ensure_window(name: "work", root: "/tmp/work")
83
+ second = adapter.ensure_window(name: "work", root: "/tmp/work")
84
+
85
+ assert_equal first, second
86
+ assert_equal 1, fixture.calls.count { |call| call[:op] == :create_window }
87
+ end
88
+
89
+ def test_ensure_window_conflicting_root_raises_window_conflict
90
+ adapter.ensure_window(name: "work", root: "/tmp/work")
91
+
92
+ error = assert_raises(WindowConflictError) do
93
+ adapter.ensure_window(name: "work", root: "/other/root")
94
+ end
95
+
96
+ assert_match(/root|preset/i, error.message)
97
+ end
98
+
99
+ def test_ensure_window_conflicting_preset_raises_window_conflict
100
+ adapter.ensure_window(name: "work", root: "/tmp/work", preset: "main")
101
+
102
+ error = assert_raises(WindowConflictError) do
103
+ adapter.ensure_window(name: "work", root: "/tmp/work", preset: "other")
104
+ end
105
+
106
+ assert_match(/preset/i, error.message)
107
+ end
108
+
109
+ # --- prepare_pane ---
110
+
111
+ def test_prepare_pane_splits_and_retains_a_writable_pane
112
+ adapter.ensure_window(name: "work", root: "/tmp/work")
113
+
114
+ pane = adapter.prepare_pane(window: "work")
115
+
116
+ assert_instance_of String, pane
117
+ refute pane.strip.empty?, "pane handle must be opaque but present"
118
+ assert fixture.calls.any? { |call| call[:op] == :split_window }, "expected a split"
119
+ assert_includes fixture.calls, {op: :set_keep_alive, pane: pane, value: true}
120
+ end
121
+
122
+ def test_prepare_pane_reuses_retained_pane_without_splitting
123
+ adapter.ensure_window(name: "work", root: "/tmp/work")
124
+ first = adapter.prepare_pane(window: "work")
125
+ fixture.calls.clear
126
+
127
+ second = adapter.prepare_pane(window: "work")
128
+
129
+ assert_equal first, second
130
+ assert_empty fixture.calls.select { |call| call[:op] == :split_window }
131
+ end
132
+
133
+ def test_prepare_pane_splits_when_only_a_bare_pane_exists
134
+ fixture.seed_window("work", root: "/tmp/work")
135
+ fixture.seed_pane("work", keep_alive: false)
136
+ bare_pane = fixture.panes.keys.first
137
+
138
+ pane = adapter.prepare_pane(window: "work")
139
+
140
+ assert fixture.calls.any? { |call| call[:op] == :split_window }, "bare pane must not satisfy preparation"
141
+ refute_equal bare_pane, pane
142
+ end
143
+
144
+ def test_prepared_pane_stays_targetable_after_command_exit
145
+ adapter.ensure_window(name: "work", root: "/tmp/work")
146
+ pane = adapter.prepare_pane(window: "work")
147
+ fixture.set_output(pane, "command ran\n")
148
+ fixture.exit_pane(pane)
149
+
150
+ assert adapter.wait_lifecycle(condition: "pane-exists", target: pane, timeout: WAIT_TIMEOUT)
151
+ assert_equal "command ran\n", adapter.capture(pane: pane, lines: 10)
152
+ end
153
+
154
+ # --- focus / close / listing ---
155
+
156
+ def test_focus_selects_window
157
+ adapter.ensure_window(name: "work", root: "/tmp/work")
158
+
159
+ adapter.focus(window: "work")
160
+
161
+ assert fixture.calls.any? { |call| call[:op] == :focus_window && call[:name] == "work" }
162
+ entries = adapter.list_windows
163
+ assert entries.find { |entry| entry[:name] == "work" }[:active]
164
+ end
165
+
166
+ def test_close_window_removes_window
167
+ adapter.ensure_window(name: "work", root: "/tmp/work")
168
+
169
+ adapter.close_window(window: "work")
170
+
171
+ refute adapter.list_windows.any? { |entry| entry[:name] == "work" }
172
+ end
173
+
174
+ def test_list_windows_reports_scripted_windows
175
+ fixture.seed_window("alpha", root: "/tmp/a")
176
+ fixture.seed_window("beta", root: "/tmp/b")
177
+
178
+ names = adapter.list_windows.map { |entry| entry[:name] }
179
+
180
+ assert_equal %w[alpha beta], names.sort
181
+ end
182
+
183
+ def test_list_panes_reports_opaque_handles
184
+ fixture.seed_window("work", root: "/tmp/work")
185
+ fixture.seed_pane("work")
186
+ fixture.seed_pane("work")
187
+
188
+ entries = adapter.list_panes(window: "work")
189
+
190
+ assert_equal 2, entries.length
191
+ entries.each do |entry|
192
+ assert_instance_of String, entry[:pane]
193
+ refute entry[:pane].strip.empty?
194
+ end
195
+ end
196
+
197
+ # --- capture ---
198
+
199
+ def test_capture_returns_scripted_output
200
+ pane = prepare_target!
201
+ fixture.set_output(pane, "line one\nline two\n")
202
+
203
+ assert_equal "line one\nline two\n", adapter.capture(pane: pane, lines: 40)
204
+ end
205
+
206
+ def test_capture_is_bounded_by_lines
207
+ pane = prepare_target!
208
+ fixture.set_output(pane, "l1\nl2\nl3\n")
209
+
210
+ assert_equal "l2\nl3\n", adapter.capture(pane: pane, lines: 2)
211
+ end
212
+
213
+ def test_capture_missing_pane_raises_target_not_found
214
+ prepare_target!
215
+
216
+ assert_raises(TargetNotFoundError) do
217
+ adapter.capture(pane: "%999", lines: 10)
218
+ end
219
+ end
220
+
221
+ # --- waits ---
222
+
223
+ def test_wait_output_returns_when_pattern_present
224
+ pane = prepare_target!
225
+ fixture.set_output(pane, "build finished\n")
226
+
227
+ assert adapter.wait_output(pane: pane, pattern: "build finished", timeout: WAIT_TIMEOUT)
228
+ end
229
+
230
+ def test_wait_output_times_out_with_seconds_context
231
+ pane = prepare_target!
232
+
233
+ error = assert_raises(WaitTimeoutError) do
234
+ adapter.wait_output(pane: pane, pattern: "never appears", timeout: WAIT_TIMEOUT)
235
+ end
236
+
237
+ assert_equal WAIT_TIMEOUT, error.timeout
238
+ assert_match(/output/, error.message)
239
+ end
240
+
241
+ def test_wait_agent_returns_when_state_matches
242
+ pane = prepare_target!
243
+ fixture.set_agent_state(pane, "working")
244
+
245
+ assert adapter.wait_agent(pane: pane, states: ["idle", "working"], timeout: WAIT_TIMEOUT)
246
+ end
247
+
248
+ def test_wait_agent_times_out_when_state_never_matches
249
+ pane = prepare_target!
250
+ fixture.set_agent_state(pane, "working")
251
+
252
+ error = assert_raises(WaitTimeoutError) do
253
+ adapter.wait_agent(pane: pane, states: ["idle"], timeout: WAIT_TIMEOUT)
254
+ end
255
+
256
+ assert_equal WAIT_TIMEOUT, error.timeout
257
+ end
258
+
259
+ def test_wait_lifecycle_rejects_conditions_outside_the_observation_set
260
+ pane = prepare_target!
261
+
262
+ error = assert_raises(ArgumentError) do
263
+ adapter.wait_lifecycle(condition: "subtree-complete", target: pane, timeout: WAIT_TIMEOUT)
264
+ end
265
+
266
+ assert_match(/window-exists|lifecycle/, error.message)
267
+ end
268
+
269
+ def test_lifecycle_window_conditions
270
+ adapter.ensure_window(name: "work", root: "/tmp/work")
271
+
272
+ assert adapter.wait_lifecycle(condition: "window-exists", target: "work", timeout: WAIT_TIMEOUT)
273
+
274
+ adapter.focus(window: "work")
275
+
276
+ assert adapter.wait_lifecycle(condition: "window-active", target: "work", timeout: WAIT_TIMEOUT)
277
+ end
278
+
279
+ def test_lifecycle_pane_conditions
280
+ pane = prepare_target!
281
+
282
+ assert adapter.wait_lifecycle(condition: "pane-exists", target: pane, timeout: WAIT_TIMEOUT)
283
+
284
+ fixture.exit_pane(pane)
285
+
286
+ assert adapter.wait_lifecycle(condition: "pane-exited", target: pane, timeout: WAIT_TIMEOUT)
287
+ end
288
+
289
+ def test_lifecycle_condition_times_out_when_unmet
290
+ pane = prepare_target!
291
+
292
+ error = assert_raises(WaitTimeoutError) do
293
+ adapter.wait_lifecycle(condition: "pane-exited", target: pane, timeout: WAIT_TIMEOUT)
294
+ end
295
+
296
+ assert_equal WAIT_TIMEOUT, error.timeout
297
+ end
298
+
299
+ # pane-exited is an observation only: the contract never treats
300
+ # it as assignment success, receipt confirmation, or prune
301
+ # authorization. That boundary is enforced above the contract;
302
+ # the suite asserts the observation itself.
303
+ def test_pane_exited_is_observation_only
304
+ fixture.seed_window("work", root: "/tmp/work")
305
+ pane = fixture.seed_pane("work")
306
+ fixture.exit_pane(pane)
307
+
308
+ assert adapter.wait_lifecycle(condition: "pane-exited", target: pane, timeout: WAIT_TIMEOUT)
309
+ end
310
+
311
+ # --- error model ---
312
+
313
+ # Targeted operations must translate native "missing target"
314
+ # failures into the typed contract error; a leaked fixture or
315
+ # native error class fails the acceptance bar.
316
+ def test_missing_targets_raise_target_not_found_on_targeted_operations
317
+ prepare_target!
318
+
319
+ assert_raises(TargetNotFoundError) { adapter.focus(window: "no-such-window") }
320
+ assert_raises(TargetNotFoundError) { adapter.close_window(window: "no-such-window") }
321
+ assert_raises(TargetNotFoundError) { adapter.prepare_pane(window: "no-such-window") }
322
+ assert_raises(TargetNotFoundError) { adapter.send(pane: "%999", command: "run", items: []) }
323
+ assert_raises(TargetNotFoundError) { adapter.send_text(pane: "%999", text: "hi") }
324
+ assert_raises(TargetNotFoundError) { adapter.send_keys(pane: "%999", keys: ["C-c"]) }
325
+ assert_raises(TargetNotFoundError) { adapter.capture(pane: "%999", lines: 10) }
326
+ assert_raises(TargetNotFoundError) { adapter.wait_output(pane: "%999", pattern: "x", timeout: WAIT_TIMEOUT) }
327
+ assert_raises(TargetNotFoundError) { adapter.wait_agent(pane: "%999", states: ["idle"], timeout: WAIT_TIMEOUT) }
328
+ end
329
+
330
+ # Listing an absent window is an empty set (poll-friendly), but
331
+ # every transport operation must fail typed when the runtime is
332
+ # unreachable — never silently succeed or leak a native error.
333
+ def test_unavailable_runtime_raises_runtime_unavailable_on_transport_operations
334
+ prepare_target!
335
+ fixture.unavailable = true
336
+
337
+ assert_raises(RuntimeUnavailableError) { adapter.ensure_window(name: "other", root: "/tmp/other") }
338
+ assert_raises(RuntimeUnavailableError) { adapter.prepare_pane(window: "work") }
339
+ assert_raises(RuntimeUnavailableError) { adapter.focus(window: "work") }
340
+ assert_raises(RuntimeUnavailableError) { adapter.send(pane: @contract_pane, command: "run", items: []) }
341
+ assert_raises(RuntimeUnavailableError) { adapter.send_command(pane: @contract_pane, command: "run") }
342
+ assert_raises(RuntimeUnavailableError) { adapter.capture(pane: @contract_pane, lines: 10) }
343
+ assert_raises(RuntimeUnavailableError) { adapter.wait_output(pane: @contract_pane, pattern: "x", timeout: WAIT_TIMEOUT) }
344
+ assert_raises(RuntimeUnavailableError) { adapter.wait_agent(pane: @contract_pane, states: ["idle"], timeout: WAIT_TIMEOUT) }
345
+ assert_raises(RuntimeUnavailableError) { adapter.wait_lifecycle(condition: "window-exists", target: "work", timeout: WAIT_TIMEOUT) }
346
+ assert_raises(RuntimeUnavailableError) { adapter.close_window(window: "work") }
347
+ assert_raises(RuntimeUnavailableError) { adapter.list_windows }
348
+ assert_raises(RuntimeUnavailableError) { adapter.list_panes(window: "work") }
349
+ end
350
+
351
+ def test_stalled_send_raises_send_stalled_error_and_is_never_auto_resent
352
+ pane = prepare_target!
353
+
354
+ fixture.stall_next_send!
355
+
356
+ assert_raises(SendStalledError) do
357
+ adapter.send(pane: pane, command: "run", items: [])
358
+ end
359
+ assert_empty transport_calls, "a stalled submission must not have delivered anything"
360
+
361
+ # The contract never auto-resends; an explicit caller retry is
362
+ # a new submission and may deliver.
363
+ adapter.send(pane: pane, command: "run", items: [])
364
+ expected_delivery = adapter.send_profile == :plain_pane ? 2 : 1
365
+ assert_equal expected_delivery, transport_calls.length
366
+ end
367
+
368
+ def test_send_to_missing_pane_raises_target_not_found
369
+ prepare_target!
370
+
371
+ assert_raises(TargetNotFoundError) do
372
+ adapter.send(pane: "%999", command: "run", items: [])
373
+ end
374
+ end
375
+
376
+ # --- send matrix (common, both profiles) ---
377
+
378
+ def test_empty_send_is_rejected_before_transport
379
+ with_target do
380
+ assert_rejected_before_transport(command: nil, items: [])
381
+ end
382
+ end
383
+
384
+ def test_invalid_item_shape_is_rejected_before_transport
385
+ with_target do
386
+ assert_rejected_before_transport(command: nil, items: [{blob: "x"}])
387
+ end
388
+ end
389
+
390
+ def test_command_with_message_item_is_rejected_before_transport
391
+ with_target do
392
+ assert_rejected_before_transport(command: "run", items: [{message: "hi"}])
393
+ end
394
+ end
395
+
396
+ def test_send_result_reports_when_no_enter_was_dropped
397
+ pane = prepare_target!
398
+
399
+ result = adapter.send(pane: pane, command: "run", items: [])
400
+
401
+ refute result.dropped_trailing_enter
402
+ end
403
+
404
+ # --- shared helpers ---
405
+
406
+ private
407
+
408
+ # Transport ops recorded after target preparation — the calls
409
+ # the code under test actually made.
410
+ def transport_calls
411
+ fixture.calls.drop(@contract_setup_op_count || 0)
412
+ end
413
+
414
+ # Prepares a retained target and returns its opaque pane handle.
415
+ def prepare_target!(name: "work", root: "/tmp/ace-runtime-contract")
416
+ adapter.ensure_window(name: name, root: root)
417
+ pane = adapter.prepare_pane(window: name)
418
+ fixture.set_output(pane, "ready\n")
419
+ @contract_setup_op_count = fixture.calls.length
420
+ @contract_pane = pane
421
+ end
422
+
423
+ def with_target
424
+ prepare_target! unless @contract_pane
425
+ yield
426
+ end
427
+
428
+ def assert_rejected_before_transport(command:, items:)
429
+ error = assert_raises(SendRejectedError) do
430
+ adapter.send(pane: @contract_pane, command: command, items: items)
431
+ end
432
+
433
+ assert_empty transport_calls, "rejected send must not reach transport"
434
+ error
435
+ end
436
+ end
437
+
438
+ module AdapterContract
439
+ # Normative matrix for adapters whose send_profile is
440
+ # :plain_pane: messages are raw text (no implicit submission),
441
+ # keys are keystrokes, each Enter submits pending text, and
442
+ # command submits once before its trailing keys.
443
+ module PlainPaneSendMatrix
444
+ def test_ordered_items_deliver_distinctly
445
+ prepare_target!
446
+
447
+ adapter.send(
448
+ pane: @contract_pane,
449
+ command: nil,
450
+ items: [{message: "one"}, {key: "Enter"}, {message: "two"}]
451
+ )
452
+
453
+ assert_equal [
454
+ {op: :write_text, pane: @contract_pane, text: "one"},
455
+ {op: :press_key, pane: @contract_pane, key: "Enter"},
456
+ {op: :write_text, pane: @contract_pane, text: "two"}
457
+ ], transport_calls
458
+ end
459
+
460
+ def test_messages_with_single_trailing_enter_submit_exactly_once
461
+ prepare_target!
462
+
463
+ result = adapter.send(
464
+ pane: @contract_pane,
465
+ command: nil,
466
+ items: [{message: "hello"}, {key: "Enter"}]
467
+ )
468
+
469
+ assert_equal [
470
+ {op: :write_text, pane: @contract_pane, text: "hello"},
471
+ {op: :press_key, pane: @contract_pane, key: "Enter"}
472
+ ], transport_calls
473
+ refute result.dropped_trailing_enter
474
+ end
475
+
476
+ def test_command_submits_once_then_delivers_trailing_keys
477
+ prepare_target!
478
+
479
+ adapter.send(pane: @contract_pane, command: "run", items: [{key: "C-c"}])
480
+
481
+ assert_equal [
482
+ {op: :write_text, pane: @contract_pane, text: "run"},
483
+ {op: :press_key, pane: @contract_pane, key: "Enter"},
484
+ {op: :press_key, pane: @contract_pane, key: "C-c"}
485
+ ], transport_calls
486
+ end
487
+
488
+ def test_convenience_shapes_delegate_to_send
489
+ prepare_target!
490
+
491
+ adapter.send_command(pane: @contract_pane, command: "run")
492
+ adapter.send_text(pane: @contract_pane, text: "hi")
493
+ adapter.send_keys(pane: @contract_pane, keys: %w[C-c C-r])
494
+
495
+ assert_equal [
496
+ {op: :write_text, pane: @contract_pane, text: "run"},
497
+ {op: :press_key, pane: @contract_pane, key: "Enter"},
498
+ {op: :write_text, pane: @contract_pane, text: "hi"},
499
+ {op: :press_key, pane: @contract_pane, key: "C-c"},
500
+ {op: :press_key, pane: @contract_pane, key: "C-r"}
501
+ ], transport_calls
502
+ end
503
+ end
504
+
505
+ # Normative matrix for adapters whose send_profile is
506
+ # :agent_aware: command or the concatenated messages become ONE
507
+ # self-submitting prompt, at most one trailing Enter is dropped
508
+ # and reported, keys-only sequences go to the agent, and every
509
+ # other interleaving is rejected before any transport call.
510
+ module AgentAwareSendMatrix
511
+ def test_messages_deliver_as_one_prompt_without_enter
512
+ prepare_target!
513
+
514
+ result = adapter.send(
515
+ pane: @contract_pane,
516
+ command: nil,
517
+ items: [{message: "line one"}, {message: "line two"}]
518
+ )
519
+
520
+ assert_equal [{op: :write_text, pane: @contract_pane, text: "line one\nline two"}], transport_calls
521
+ refute result.dropped_trailing_enter
522
+ end
523
+
524
+ def test_single_trailing_enter_after_messages_is_dropped_and_reported
525
+ prepare_target!
526
+
527
+ result = adapter.send(
528
+ pane: @contract_pane,
529
+ command: nil,
530
+ items: [{message: "hello"}, {key: "Enter"}]
531
+ )
532
+
533
+ assert_equal [{op: :write_text, pane: @contract_pane, text: "hello"}], transport_calls
534
+ assert result.dropped_trailing_enter
535
+ end
536
+
537
+ def test_command_with_single_trailing_enter_is_dropped_and_reported
538
+ prepare_target!
539
+
540
+ result = adapter.send(pane: @contract_pane, command: "run", items: [{key: "Enter"}])
541
+
542
+ assert_equal [{op: :write_text, pane: @contract_pane, text: "run"}], transport_calls
543
+ assert result.dropped_trailing_enter
544
+ end
545
+
546
+ def test_keys_only_sequences_go_to_agent_keys
547
+ prepare_target!
548
+
549
+ result = adapter.send(
550
+ pane: @contract_pane,
551
+ command: nil,
552
+ items: [{key: "C-c"}, {key: "C-r"}]
553
+ )
554
+
555
+ assert_equal [
556
+ {op: :press_key, pane: @contract_pane, key: "C-c"},
557
+ {op: :press_key, pane: @contract_pane, key: "C-r"}
558
+ ], transport_calls
559
+ refute result.dropped_trailing_enter
560
+ end
561
+
562
+ def test_send_text_delivers_single_prompt
563
+ prepare_target!
564
+
565
+ adapter.send_text(pane: @contract_pane, text: "hello")
566
+
567
+ assert_equal [{op: :write_text, pane: @contract_pane, text: "hello"}], transport_calls
568
+ end
569
+
570
+ def test_keys_between_messages_rejected_before_transport
571
+ prepare_target!
572
+
573
+ assert_rejected_before_transport(
574
+ command: nil,
575
+ items: [{message: "a"}, {key: "C-c"}, {message: "b"}]
576
+ )
577
+ end
578
+
579
+ def test_multiple_enters_rejected_before_transport
580
+ prepare_target!
581
+
582
+ assert_rejected_before_transport(
583
+ command: nil,
584
+ items: [{message: "a"}, {key: "Enter"}, {key: "Enter"}]
585
+ )
586
+ end
587
+
588
+ def test_command_with_non_enter_key_rejected_before_transport
589
+ prepare_target!
590
+
591
+ assert_rejected_before_transport(command: "run", items: [{key: "C-c"}])
592
+ end
593
+ end
594
+ end
595
+ end
596
+ end
597
+ end