@selesai/code 0.8.5 → 0.8.6

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/defaults/models.json +5 -0
  3. package/dist/extensions/agent-browser.test.ts +178 -2
  4. package/dist/extensions/agent-browser.ts +2 -0
  5. package/dist/extensions/copy-turn.test.ts +229 -0
  6. package/dist/extensions/copy-turn.ts +11 -0
  7. package/dist/extensions/grep-app/index.test.ts +356 -1
  8. package/dist/extensions/grep-app/index.ts +6 -1
  9. package/dist/extensions/handoff-new.test.ts +351 -161
  10. package/dist/extensions/handoff-new.ts +3 -0
  11. package/dist/extensions/inline-skills.test.ts +93 -0
  12. package/dist/extensions/inline-skills.ts +3 -0
  13. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  14. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  15. package/dist/extensions/pi-subagents/docs/configuration.md +8 -0
  16. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  17. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  18. package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
  19. package/dist/extensions/pi-subagents/src/extension/index.ts +83 -14
  20. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  21. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +3 -0
  22. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  23. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  24. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  25. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  26. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +7 -5
  27. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  28. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  29. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +39 -9
  30. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  31. package/dist/extensions/pi-subagents/src/shared/types.ts +16 -0
  32. package/dist/extensions/pi-subagents/src/tui/fleet.ts +1 -1
  33. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  34. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  35. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  36. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  37. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  38. package/dist/extensions/ponytail/index.js +11 -0
  39. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  40. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  41. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  42. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  43. package/dist/extensions/question/index.ts +18 -0
  44. package/dist/extensions/question/question-list.ts +22 -0
  45. package/dist/extensions/question/tests/batch.test.ts +103 -65
  46. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  47. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  48. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  49. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  50. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  51. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  52. package/dist/extensions/rtk.test.ts +180 -1
  53. package/dist/extensions/tokenin-onboarding.ts +3 -0
  54. package/dist/extensions/undo.test.ts +720 -0
  55. package/dist/extensions/undo.ts +6 -0
  56. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  57. package/dist/extensions/workflow/extension.ts +3 -3
  58. package/dist/extensions/workflow/modes.ts +41 -19
  59. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  60. package/docs/plans/workflow-autoloop-reference.md +1 -2
  61. package/docs/plans/workflow-handoff-carryover.md +108 -0
  62. package/package.json +4 -1
@@ -1,136 +1,202 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
1
+ import { describe, expect, it } from "vitest";
3
2
 
4
3
  import { wrapPlain, buildItemBlocks, flattenBlocks, renderSingleSelectRows } from "../row-layout.ts";
5
4
  import type { ItemBlock, QuestionOption } from "../types.ts";
6
5
 
7
6
  const opts = (labels: string[]): QuestionOption[] => labels.map((label) => ({ value: label, label }));
8
7
 
9
- test("wrapPlain: empty → ['']", () => {
10
- assert.deepEqual(wrapPlain("", 20), [""]);
11
- assert.deepEqual(wrapPlain(" ", 20), [""]);
12
- });
8
+ describe("row-layout", () => {
9
+ it("wrapPlain: empty → ['']", () => {
10
+ expect(wrapPlain("", 20)).toEqual([""]);
11
+ expect(wrapPlain(" ", 20)).toEqual([""]);
12
+ });
13
13
 
14
- test("wrapPlain: single word fits", () => {
15
- assert.deepEqual(wrapPlain("hello", 20), ["hello"]);
16
- });
14
+ it("wrapPlain: single word fits", () => {
15
+ expect(wrapPlain("hello", 20)).toEqual(["hello"]);
16
+ });
17
17
 
18
- test("wrapPlain: word longer than width → chunked", () => {
19
- assert.deepEqual(wrapPlain("abcdefgh", 3), ["abc", "def", "gh"]);
20
- });
18
+ it("wrapPlain: word longer than width → chunked", () => {
19
+ expect(wrapPlain("abcdefgh", 3)).toEqual(["abc", "def", "gh"]);
20
+ });
21
21
 
22
- test("wrapPlain: multiple words wrapping", () => {
23
- assert.deepEqual(wrapPlain("hello world foo", 7), ["hello", "world", "foo"]);
24
- assert.deepEqual(wrapPlain("hello world foo", 11), ["hello world", "foo"]);
25
- });
22
+ it("wrapPlain: multiple words wrapping", () => {
23
+ expect(wrapPlain("hello world foo", 7)).toEqual(["hello", "world", "foo"]);
24
+ expect(wrapPlain("hello world foo", 11)).toEqual(["hello world", "foo"]);
25
+ });
26
26
 
27
- test("wrapPlain: width=1 → char-by-char", () => {
28
- assert.deepEqual(wrapPlain("abc", 1), ["a", "b", "c"]);
29
- });
27
+ it("wrapPlain: width=1 → char-by-char", () => {
28
+ expect(wrapPlain("abc", 1)).toEqual(["a", "b", "c"]);
29
+ });
30
30
 
31
- test("buildItemBlocks: 3 options, selected=0 → arrow on block 0", () => {
32
- const blocks = buildItemBlocks(opts(["a", "b", "c"]), 80, false, false, false, 0);
33
- assert.equal(blocks.length, 3);
34
- assert.equal(blocks[0]!.itemIndex, 0);
35
- assert.ok(blocks[0]!.lines[0]!.startsWith("→"));
36
- assert.ok(blocks[0]!.lines[0]!.includes("1. a"));
37
- assert.ok(blocks[1]!.lines[0]!.startsWith(" "));
38
- });
31
+ it("wrapPlain: long word mid-sentence chunks and continues", () => {
32
+ expect(wrapPlain("a verylongwordhere b", 5)).toEqual(["a", "veryl", "ongwo", "rdher", "e b"]);
33
+ });
39
34
 
40
- test("buildItemBlocks: selected=1 → arrow pointer on block 1", () => {
41
- const blocks = buildItemBlocks(opts(["a", "b", "c"]), 80, false, false, false, 1);
42
- assert.ok(blocks[1]!.lines[0]!.startsWith("→"));
43
- assert.ok(blocks[0]!.lines[0]!.startsWith(" "));
44
- });
35
+ it("buildItemBlocks: 3 options, selected=0 → arrow on block 0", () => {
36
+ const blocks = buildItemBlocks(opts(["a", "b", "c"]), 80, false, false, false, 0);
37
+ expect(blocks).toHaveLength(3);
38
+ expect(blocks[0]!.itemIndex).toBe(0);
39
+ expect(blocks[0]!.lines[0]!.startsWith("→")).toBe(true);
40
+ expect(blocks[0]!.lines[0]!).toContain("1. a");
41
+ expect(blocks[1]!.lines[0]!.startsWith(" ")).toBe(true);
42
+ });
45
43
 
46
- test("buildItemBlocks: with description → description lines included", () => {
47
- const blocks = buildItemBlocks(
48
- [{ value: "a", label: "a", description: "some desc" }],
49
- 80,
50
- false,
51
- false,
52
- false,
53
- 0,
54
- );
55
- assert.ok(blocks[0]!.lines.length > 1);
56
- assert.ok(blocks[0]!.lines.some((l) => l.includes("some desc")));
57
- });
44
+ it("buildItemBlocks: selected=1 → arrow pointer on block 1", () => {
45
+ const blocks = buildItemBlocks(opts(["a", "b", "c"]), 80, false, false, false, 1);
46
+ expect(blocks[1]!.lines[0]!.startsWith("→")).toBe(true);
47
+ expect(blocks[0]!.lines[0]!.startsWith(" ")).toBe(true);
48
+ });
58
49
 
59
- test("buildItemBlocks: hideDescriptions=true → no description lines", () => {
60
- const blocks = buildItemBlocks(
61
- [{ value: "a", label: "a", description: "some desc" }],
62
- 80,
63
- false,
64
- false,
65
- false,
66
- 0,
67
- true,
68
- );
69
- // Only the title line, no description
70
- assert.equal(blocks[0]!.lines.length, 1);
71
- });
50
+ it("buildItemBlocks: with description → description lines included", () => {
51
+ const blocks = buildItemBlocks(
52
+ [{ value: "a", label: "a", description: "some desc" }],
53
+ 80,
54
+ false,
55
+ false,
56
+ false,
57
+ 0,
58
+ );
59
+ expect(blocks[0]!.lines.length).toBeGreaterThan(1);
60
+ expect(blocks[0]!.lines.some((l) => l.includes("some desc"))).toBe(true);
61
+ });
72
62
 
73
- test("buildItemBlocks: allowFreeform → freeform block appended", () => {
74
- const blocks = buildItemBlocks(opts(["a"]), 80, true, false, false, 0);
75
- assert.equal(blocks.length, 2);
76
- assert.ok(blocks[1]!.lines[0]!.includes("Type custom answer"));
77
- });
63
+ it("buildItemBlocks: hideDescriptions=true → no description lines", () => {
64
+ const blocks = buildItemBlocks(
65
+ [{ value: "a", label: "a", description: "some desc" }],
66
+ 80,
67
+ false,
68
+ false,
69
+ false,
70
+ 0,
71
+ true,
72
+ );
73
+ // Only the title line, no description
74
+ expect(blocks[0]!.lines).toHaveLength(1);
75
+ });
78
76
 
79
- test("buildItemBlocks: allowComment → comment-toggle block appended", () => {
80
- const blocks = buildItemBlocks(opts(["a"]), 80, false, true, false, 0);
81
- assert.equal(blocks.length, 2);
82
- assert.ok(blocks[1]!.lines[0]!.includes("[ ]"));
83
- assert.ok(blocks[1]!.lines[0]!.includes("Add extra context"));
84
- });
77
+ it("buildItemBlocks: allowFreeform → freeform block appended", () => {
78
+ const blocks = buildItemBlocks(opts(["a"]), 80, true, false, false, 0);
79
+ expect(blocks).toHaveLength(2);
80
+ expect(blocks[1]!.lines[0]!).toContain("Type custom answer");
81
+ });
85
82
 
86
- test("buildItemBlocks: allowComment + commentEnabled → [✓]", () => {
87
- const blocks = buildItemBlocks(opts(["a"]), 80, false, true, true, 0);
88
- assert.ok(blocks[1]!.lines[0]!.includes("[✓]"));
89
- });
83
+ it("buildItemBlocks: allowComment → comment-toggle block appended", () => {
84
+ const blocks = buildItemBlocks(opts(["a"]), 80, false, true, false, 0);
85
+ expect(blocks).toHaveLength(2);
86
+ expect(blocks[1]!.lines[0]!).toContain("[ ]");
87
+ expect(blocks[1]!.lines[0]!).toContain("Add extra context");
88
+ });
90
89
 
91
- test("flattenBlocks: correct selected flags", () => {
92
- const blocks: ItemBlock[] = [
93
- { itemIndex: 0, lines: ["a", "b"] },
94
- { itemIndex: 1, lines: ["c"] },
95
- ];
96
- const rows = flattenBlocks(blocks, 1);
97
- assert.equal(rows.length, 3);
98
- assert.equal(rows[0]!.selected, false);
99
- assert.equal(rows[1]!.selected, false);
100
- assert.equal(rows[2]!.selected, true);
101
- });
90
+ it("buildItemBlocks: allowComment + commentEnabled → [✓]", () => {
91
+ const blocks = buildItemBlocks(opts(["a"]), 80, false, true, true, 0);
92
+ expect(blocks[1]!.lines[0]!).toContain("[✓]");
93
+ });
102
94
 
103
- test("renderSingleSelectRows: few rows, all visible", () => {
104
- const rows = renderSingleSelectRows({
105
- options: opts(["a", "b"]),
106
- selectedIndex: 0,
107
- width: 80,
108
- allowFreeform: false,
95
+ it("buildItemBlocks: narrow width wraps labels", () => {
96
+ const blocks = buildItemBlocks(opts(["a very long label"]), 12, false, false, false, 0);
97
+ expect(blocks[0]!.lines.length).toBeGreaterThan(1);
109
98
  });
110
- assert.equal(rows.length, 2);
111
- });
112
99
 
113
- test("renderSingleSelectRows: maxRows truncation with indicator", () => {
114
- const rows = renderSingleSelectRows({
115
- options: opts(["a", "b", "c", "c", "e", "f", "g", "h"]),
116
- selectedIndex: 0,
117
- width: 80,
118
- allowFreeform: false,
119
- maxRows: 3,
120
- });
121
- assert.ok(rows.length <= 3);
122
- // Last row should be the indicator
123
- assert.ok(rows[rows.length - 1]!.line.includes("("));
124
- });
100
+ it("flattenBlocks: correct selected flags", () => {
101
+ const blocks: ItemBlock[] = [
102
+ { itemIndex: 0, lines: ["a", "b"] },
103
+ { itemIndex: 1, lines: ["c"] },
104
+ ];
105
+ const rows = flattenBlocks(blocks, 1);
106
+ expect(rows).toHaveLength(3);
107
+ expect(rows[0]!.selected).toBe(false);
108
+ expect(rows[1]!.selected).toBe(false);
109
+ expect(rows[2]!.selected).toBe(true);
110
+ });
111
+
112
+ it("renderSingleSelectRows: few rows, all visible", () => {
113
+ const rows = renderSingleSelectRows({
114
+ options: opts(["a", "b"]),
115
+ selectedIndex: 0,
116
+ width: 80,
117
+ allowFreeform: false,
118
+ });
119
+ expect(rows).toHaveLength(2);
120
+ });
121
+
122
+ it("renderSingleSelectRows: maxRows truncation with indicator", () => {
123
+ const rows = renderSingleSelectRows({
124
+ options: opts(["a", "b", "c", "c", "e", "f", "g", "h"]),
125
+ selectedIndex: 0,
126
+ width: 80,
127
+ allowFreeform: false,
128
+ maxRows: 3,
129
+ });
130
+ expect(rows.length).toBeLessThanOrEqual(3);
131
+ // Last row should be the indicator
132
+ expect(rows[rows.length - 1]!.line).toContain("(");
133
+ });
134
+
135
+ it("renderSingleSelectRows: selected block larger than available → only selected + indicator", () => {
136
+ const rows = renderSingleSelectRows({
137
+ options: [{ value: "a", label: "a".repeat(100) }],
138
+ selectedIndex: 0,
139
+ width: 10,
140
+ allowFreeform: false,
141
+ maxRows: 3,
142
+ });
143
+ expect(rows.length).toBeLessThanOrEqual(3);
144
+ expect(rows.every((r) => r.selected || r.line.includes("("))).toBe(true);
145
+ });
146
+
147
+ it("renderSingleSelectRows: maxRows=1 shows only the selected line", () => {
148
+ const rows = renderSingleSelectRows({
149
+ options: opts(["a", "b"]),
150
+ selectedIndex: 0,
151
+ width: 80,
152
+ allowFreeform: false,
153
+ maxRows: 1,
154
+ });
155
+ expect(rows.length).toBeLessThanOrEqual(1);
156
+ });
125
157
 
126
- test("renderSingleSelectRows: selected block larger than available → only selected + indicator", () => {
127
- const rows = renderSingleSelectRows({
128
- options: [{ value: "a", label: "a".repeat(100) }],
129
- selectedIndex: 0,
130
- width: 10,
131
- allowFreeform: false,
132
- maxRows: 3,
133
- });
134
- assert.ok(rows.length <= 3);
135
- assert.ok(rows.every((r) => r.selected || r.line.includes("(")));
136
- });
158
+ it("renderSingleSelectRows: empty options with freeform still renders", () => {
159
+ const rows = renderSingleSelectRows({
160
+ options: [],
161
+ selectedIndex: 0,
162
+ width: 80,
163
+ allowFreeform: true,
164
+ maxRows: 5,
165
+ });
166
+ expect(rows.length).toBeGreaterThan(0);
167
+ });
168
+
169
+ it("renderSingleSelectRows: empty options without freeform returns empty", () => {
170
+ const rows = renderSingleSelectRows({
171
+ options: [],
172
+ selectedIndex: 0,
173
+ width: 80,
174
+ allowFreeform: false,
175
+ maxRows: 5,
176
+ });
177
+ expect(rows).toEqual([]);
178
+ });
179
+
180
+ it("renderSingleSelectRows: previous block fits when next does not", () => {
181
+ // Selected block is small; the next block is too large but the previous fits.
182
+ const rows = renderSingleSelectRows({
183
+ options: [
184
+ { value: "a", label: "A" },
185
+ { value: "b", label: "B" },
186
+ { value: "c", label: "C".repeat(100) },
187
+ ],
188
+ selectedIndex: 1,
189
+ width: 10,
190
+ allowFreeform: false,
191
+ maxRows: 3,
192
+ });
193
+ expect(rows.length).toBeLessThanOrEqual(3);
194
+ expect(rows.some((r) => r.line.includes("A"))).toBe(true);
195
+ });
196
+
197
+ it("buildItemBlocks: wrapped freeform label uses continuation prefix", () => {
198
+ const blocks = buildItemBlocks(opts(["a"]), 12, true, false, false, 1);
199
+ expect(blocks[1]!.lines.length).toBeGreaterThan(1);
200
+ expect(blocks[1]!.lines[1]!.startsWith(" ")).toBe(true);
201
+ });
202
+ });
@@ -1,5 +1,4 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
1
+ import { describe, expect, it } from "vitest";
3
2
 
4
3
  import { Value } from "typebox/value";
5
4
 
@@ -9,22 +8,46 @@ const valid = {
9
8
  questions: [{ type: "select", question: "Pick", options: [{ value: "yes", label: "Yes" }] }],
10
9
  };
11
10
 
12
- test("QuestionParamsSchema accepts typed batch requests", () => {
13
- assert.equal(Value.Check(QuestionParamsSchema, valid), true);
14
- });
11
+ describe("QuestionParamsSchema", () => {
12
+ it("accepts typed batch requests", () => {
13
+ expect(Value.Check(QuestionParamsSchema, valid)).toBe(true);
14
+ });
15
+
16
+ it("accepts multiselect, text, ids, labels, context, allowOther, descriptions", () => {
17
+ expect(Value.Check(QuestionParamsSchema, {
18
+ questions: [
19
+ { type: "multiselect", id: "m", label: "M", question: "Pick many", context: "ctx", options: [{ value: "a", label: "A", description: "d" }], allowOther: true },
20
+ { type: "text", question: "Explain" },
21
+ ],
22
+ })).toBe(true);
23
+ });
24
+
25
+ it("rejects legacy and mistyped fields", () => {
26
+ expect(Value.Check(QuestionParamsSchema, { ...valid, timeout: 1000 })).toBe(false);
27
+ expect(Value.Check(QuestionParamsSchema, {
28
+ questions: [{ ...valid.questions[0], allowMultiple: true }],
29
+ })).toBe(false);
30
+ expect(Value.Check(QuestionParamsSchema, {
31
+ questions: [{ ...valid.questions[0], allowComment: true }],
32
+ })).toBe(false);
33
+ expect(Value.Check(QuestionParamsSchema, {
34
+ questions: [{ type: "text", question: "Explain", options: [] }],
35
+ })).toBe(false);
36
+ expect(Value.Check(QuestionParamsSchema, {
37
+ questions: [{ ...valid.questions[0], options: [{ value: "yes", label: "Yes", extra: true }] }],
38
+ })).toBe(false);
39
+ });
15
40
 
16
- test("QuestionParamsSchema rejects legacy and mistyped fields", () => {
17
- assert.equal(Value.Check(QuestionParamsSchema, { ...valid, timeout: 1000 }), false);
18
- assert.equal(Value.Check(QuestionParamsSchema, {
19
- questions: [{ ...valid.questions[0], allowMultiple: true }],
20
- }), false);
21
- assert.equal(Value.Check(QuestionParamsSchema, {
22
- questions: [{ ...valid.questions[0], allowComment: true }],
23
- }), false);
24
- assert.equal(Value.Check(QuestionParamsSchema, {
25
- questions: [{ type: "text", question: "Explain", options: [] }],
26
- }), false);
27
- assert.equal(Value.Check(QuestionParamsSchema, {
28
- questions: [{ ...valid.questions[0], options: [{ value: "yes", label: "Yes", extra: true }] }],
29
- }), false);
41
+ it("rejects empty questions and bad option shapes", () => {
42
+ expect(Value.Check(QuestionParamsSchema, { questions: [] })).toBe(false);
43
+ expect(Value.Check(QuestionParamsSchema, {
44
+ questions: [{ type: "select", question: "Pick", options: [] }],
45
+ })).toBe(false);
46
+ expect(Value.Check(QuestionParamsSchema, {
47
+ questions: [{ type: "select", question: "Pick", options: [{ value: "yes" }] }],
48
+ })).toBe(false);
49
+ expect(Value.Check(QuestionParamsSchema, {
50
+ questions: [{ type: "select", question: "Pick", options: [{ label: "Yes" }] }],
51
+ })).toBe(false);
52
+ });
30
53
  });
@@ -1,85 +1,107 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
-
4
- import { normalizeShortcutSpec, isValidShortcutSpec, resolveShortcut } from "../shortcuts.ts";
5
- import { DISABLED_SHORTCUT } from "../constants.ts";
6
-
7
- test("normalizeShortcutSpec: undefined → undefined", () => {
8
- assert.equal(normalizeShortcutSpec(undefined), undefined);
9
- });
10
-
11
- test("normalizeShortcutSpec: null → null", () => {
12
- assert.equal(normalizeShortcutSpec(null), null);
13
- });
14
-
15
- test("normalizeShortcutSpec: disable values → null", () => {
16
- assert.equal(normalizeShortcutSpec("off"), null);
17
- assert.equal(normalizeShortcutSpec("none"), null);
18
- assert.equal(normalizeShortcutSpec("disabled"), null);
19
- assert.equal(normalizeShortcutSpec(""), null);
20
- assert.equal(normalizeShortcutSpec(" off "), null);
21
- });
22
-
23
- test("normalizeShortcutSpec: normalizes case and trims", () => {
24
- assert.equal(normalizeShortcutSpec("Alt+O"), "alt+o");
25
- assert.equal(normalizeShortcutSpec(" Ctrl+G "), "ctrl+g");
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { normalizeShortcutSpec, isValidShortcutSpec, resolveShortcut, resolveShortcuts, buildShortcut } from "../shortcuts.ts";
4
+ import { DISABLED_SHORTCUT, DEFAULT_COMMENT_TOGGLE_KEY } from "../constants.ts";
5
+
6
+ describe("shortcut resolution", () => {
7
+ it("normalizeShortcutSpec: undefined → undefined", () => {
8
+ expect(normalizeShortcutSpec(undefined)).toBeUndefined();
9
+ });
10
+
11
+ it("normalizeShortcutSpec: null → null", () => {
12
+ expect(normalizeShortcutSpec(null)).toBeNull();
13
+ });
14
+
15
+ it("normalizeShortcutSpec: disable values → null", () => {
16
+ expect(normalizeShortcutSpec("off")).toBeNull();
17
+ expect(normalizeShortcutSpec("none")).toBeNull();
18
+ expect(normalizeShortcutSpec("disabled")).toBeNull();
19
+ expect(normalizeShortcutSpec("")).toBeNull();
20
+ expect(normalizeShortcutSpec(" off ")).toBeNull();
21
+ });
22
+
23
+ it("normalizeShortcutSpec: normalizes case and trims", () => {
24
+ expect(normalizeShortcutSpec("Alt+O")).toBe("alt+o");
25
+ expect(normalizeShortcutSpec(" Ctrl+G ")).toBe("ctrl+g");
26
+ });
27
+
28
+ it("isValidShortcutSpec: empty → false", () => {
29
+ expect(isValidShortcutSpec("")).toBe(false);
30
+ expect(isValidShortcutSpec(" ")).toBe(false);
31
+ });
32
+
33
+ it("isValidShortcutSpec: valid specs", () => {
34
+ expect(isValidShortcutSpec("alt+o")).toBe(true);
35
+ expect(isValidShortcutSpec("ctrl+g")).toBe(true);
36
+ expect(isValidShortcutSpec("shift+f1")).toBe(true);
37
+ });
38
+
39
+ it("isValidShortcutSpec: leading/trailing + → false", () => {
40
+ expect(isValidShortcutSpec("+o")).toBe(false);
41
+ expect(isValidShortcutSpec("o+")).toBe(false);
42
+ });
43
+
44
+ it("isValidShortcutSpec: double + → false", () => {
45
+ expect(isValidShortcutSpec("alt++o")).toBe(false);
46
+ });
47
+
48
+ it("isValidShortcutSpec: illegal characters → false", () => {
49
+ expect(isValidShortcutSpec("alt o")).toBe(false);
50
+ expect(isValidShortcutSpec("alt\n")).toBe(false);
51
+ });
52
+
53
+ it("buildShortcut returns a matching shortcut", () => {
54
+ const shortcut = buildShortcut("ctrl+g");
55
+ expect(shortcut.disabled).toBe(false);
56
+ expect(shortcut.spec).toBe("ctrl+g");
57
+ expect(shortcut.matches("\x07")).toBe(true);
58
+ expect(shortcut.matches("x")).toBe(false);
59
+ });
60
+
61
+ it("resolveShortcut: uses param when valid", () => {
62
+ const result = resolveShortcut("alt+o", undefined, "alt+o");
63
+ expect(result.disabled).toBe(false);
64
+ expect(result.spec).toBe("alt+o");
65
+ });
66
+
67
+ it("resolveShortcut: param undefined, uses env", () => {
68
+ const result = resolveShortcut(undefined, "ctrl+x", "alt+o");
69
+ expect(result.disabled).toBe(false);
70
+ expect(result.spec).toBe("ctrl+x");
71
+ });
72
+
73
+ it("resolveShortcut: param null → disabled", () => {
74
+ expect(resolveShortcut(null, "ctrl+x", "alt+o")).toBe(DISABLED_SHORTCUT);
75
+ });
76
+
77
+ it("resolveShortcut: param undefined, env undefined, uses default", () => {
78
+ const result = resolveShortcut(undefined, undefined, "alt+o");
79
+ expect(result.disabled).toBe(false);
80
+ expect(result.spec).toBe("alt+o");
81
+ });
82
+
83
+ it("resolveShortcut: param 'off' → disabled", () => {
84
+ expect(resolveShortcut("off", "ctrl+x", "alt+o")).toBe(DISABLED_SHORTCUT);
85
+ });
86
+
87
+ it("resolveShortcut: invalid param falls through to env", () => {
88
+ const result = resolveShortcut("invalid++", "ctrl+x", "alt+o");
89
+ expect(result.disabled).toBe(false);
90
+ expect(result.spec).toBe("ctrl+x");
91
+ });
92
+
93
+ it("resolveShortcut: all invalid → disabled", () => {
94
+ expect(resolveShortcut("invalid++", "also++", "default++")).toBe(DISABLED_SHORTCUT);
95
+ });
96
+
97
+ it("resolveShortcuts builds the commentToggle map with the default key", () => {
98
+ const shortcuts = resolveShortcuts(undefined, undefined);
99
+ expect(shortcuts.commentToggle.disabled).toBe(false);
100
+ expect(shortcuts.commentToggle.spec).toBe(DEFAULT_COMMENT_TOGGLE_KEY);
101
+ });
102
+
103
+ it("DISABLED_SHORTCUT matches nothing", () => {
104
+ expect(DISABLED_SHORTCUT.matches("anything")).toBe(false);
105
+ expect(DISABLED_SHORTCUT.disabled).toBe(true);
106
+ });
26
107
  });
27
-
28
- test("isValidShortcutSpec: empty → false", () => {
29
- assert.equal(isValidShortcutSpec(""), false);
30
- assert.equal(isValidShortcutSpec(" "), false);
31
- });
32
-
33
- test("isValidShortcutSpec: valid specs", () => {
34
- assert.equal(isValidShortcutSpec("alt+o"), true);
35
- assert.equal(isValidShortcutSpec("ctrl+g"), true);
36
- assert.equal(isValidShortcutSpec("shift+f1"), true);
37
- });
38
-
39
- test("isValidShortcutSpec: leading/trailing + → false", () => {
40
- assert.equal(isValidShortcutSpec("+o"), false);
41
- assert.equal(isValidShortcutSpec("o+"), false);
42
- });
43
-
44
- test("isValidShortcutSpec: double + → false", () => {
45
- assert.equal(isValidShortcutSpec("alt++o"), false);
46
- });
47
-
48
- test("resolveShortcut: uses param when valid", () => {
49
- const result = resolveShortcut("alt+o", undefined, "alt+o");
50
- assert.equal(result.disabled, false);
51
- assert.equal(result.spec, "alt+o");
52
- });
53
-
54
- test("resolveShortcut: param undefined, uses env", () => {
55
- const result = resolveShortcut(undefined, "ctrl+x", "alt+o");
56
- assert.equal(result.disabled, false);
57
- assert.equal(result.spec, "ctrl+x");
58
- });
59
-
60
- test("resolveShortcut: param null → disabled", () => {
61
- const result = resolveShortcut(null, "ctrl+x", "alt+o");
62
- assert.equal(result, DISABLED_SHORTCUT);
63
- });
64
-
65
- test("resolveShortcut: param undefined, env undefined, uses default", () => {
66
- const result = resolveShortcut(undefined, undefined, "alt+o");
67
- assert.equal(result.disabled, false);
68
- assert.equal(result.spec, "alt+o");
69
- });
70
-
71
- test("resolveShortcut: param 'off' → disabled", () => {
72
- const result = resolveShortcut("off", "ctrl+x", "alt+o");
73
- assert.equal(result, DISABLED_SHORTCUT);
74
- });
75
-
76
- test("resolveShortcut: invalid param falls through to env", () => {
77
- const result = resolveShortcut("invalid++", "ctrl+x", "alt+o");
78
- assert.equal(result.disabled, false);
79
- assert.equal(result.spec, "ctrl+x");
80
- });
81
-
82
- test("resolveShortcut: all invalid → disabled", () => {
83
- const result = resolveShortcut("invalid++", "also++", "default++");
84
- assert.equal(result, DISABLED_SHORTCUT);
85
- });