ompchamber 0.8.0 → 1.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.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
@@ -0,0 +1,290 @@
1
+ import type { ComposerMatchItem, ComposerPickItem, ComposerSubcommand } from '@/shared/types';
2
+ import { SKILL_NAMESPACE, SKILL_NAMESPACE_TOKEN } from '@/shared/lib/chat/composer/trigger';
3
+
4
+ /**
5
+ * Slash-command ranking, ported from oh-my-pi's `pi-tui/autocomplete.ts`
6
+ * (`scoreCommandTextMatch`, `skillBareNameBreakoutTier`, `collapseSkillNamespace`,
7
+ * `buildSlashCommandCompletions`, `buildArgumentCompletions`). Keeping the
8
+ * formulas identical is the point: the chamber's popup must order and match
9
+ * `/`-tokens exactly like the CLI it drives.
10
+ */
11
+
12
+ /** Subsequence match, oh-my-pi's `subsequenceMatch`. */
13
+ function subsequenceMatch(query: string, target: string): boolean {
14
+ if (query.length === 0) return true;
15
+ if (query.length > target.length) return false;
16
+
17
+ let qi = 0;
18
+ for (let ti = 0; ti < target.length && qi < query.length; ti++) {
19
+ if (query[qi] === target[ti]) qi++;
20
+ }
21
+ return qi === query.length;
22
+ }
23
+
24
+ /** Ranked-tier subsequence score (100/80/60/40−gaps·5); higher is better, 0 is no match. */
25
+ function subsequenceScore(query: string, target: string): number {
26
+ if (query.length === 0) return 1;
27
+ if (target === query) return 100;
28
+ if (target.startsWith(query)) return 80;
29
+ if (target.includes(query)) return 60;
30
+
31
+ let qi = 0;
32
+ let gaps = 0;
33
+ let lastMatchIdx = -1;
34
+ for (let ti = 0; ti < target.length && qi < query.length; ti++) {
35
+ if (query[qi] === target[ti]) {
36
+ if (lastMatchIdx >= 0 && ti - lastMatchIdx > 1) gaps++;
37
+ lastMatchIdx = ti;
38
+ qi++;
39
+ }
40
+ }
41
+ if (qi !== query.length) return 0;
42
+
43
+ return Math.max(1, 40 - gaps * 5);
44
+ }
45
+
46
+ /**
47
+ * oh-my-pi's `scoreCommandTextMatch`. Prefix matches share one flat score so
48
+ * same-prefix commands keep their list order; a length penalty here would rank
49
+ * the shorter name first (e.g. `/set` → `setup` above `settings`).
50
+ */
51
+ function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
52
+ if (lowerPrefix.length === 0) return 1;
53
+ if (lowerPrefix === lowerTarget) return 1000;
54
+ if (lowerTarget.startsWith(lowerPrefix)) return 900;
55
+ return subsequenceMatch(lowerPrefix, lowerTarget) ? subsequenceScore(lowerPrefix, lowerTarget) : 0;
56
+ }
57
+
58
+ /** Exact/leading-prefix tier for ordinary command names and aliases. */
59
+ function commandBreakoutTier(lowerPrefix: string, lowerTarget: string): number {
60
+ if (lowerPrefix === lowerTarget) return 1000;
61
+ if (lowerTarget.startsWith(lowerPrefix)) return 900;
62
+ return 0;
63
+ }
64
+
65
+ /**
66
+ * Match a bare skill name from the beginning of any hyphen-delimited segment
67
+ * (oh-my-pi's `skillBareNameBreakoutTier`), so `/last` surfaces
68
+ * `skill:research-last30days`.
69
+ */
70
+ function skillBareNameBreakoutTier(lowerPrefix: string, lowerBareName: string): number {
71
+ if (lowerPrefix.length === 0) return 0;
72
+ if (lowerPrefix === lowerBareName) return 1000;
73
+ if (lowerBareName.startsWith(lowerPrefix)) return 900;
74
+
75
+ let segmentStart = 0;
76
+ while (segmentStart < lowerBareName.length) {
77
+ while (segmentStart < lowerBareName.length && lowerBareName.charCodeAt(segmentStart) !== 45) {
78
+ segmentStart += 1;
79
+ }
80
+ segmentStart += 1;
81
+ if (segmentStart >= lowerBareName.length) break;
82
+
83
+ if (lowerBareName.startsWith(lowerPrefix, segmentStart)) {
84
+ let segmentEnd = segmentStart;
85
+ while (segmentEnd < lowerBareName.length && lowerBareName.charCodeAt(segmentEnd) !== 45) {
86
+ segmentEnd += 1;
87
+ }
88
+ return lowerPrefix.length === segmentEnd - segmentStart ? 1000 : 900;
89
+ }
90
+ }
91
+
92
+ return 0;
93
+ }
94
+
95
+ /**
96
+ * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
97
+ * surface `name` (oh-my-pi's `midPromptSkillTokenMatches`). Deliberately
98
+ * stricter than submitted-command matching: a stray `/word` in running prose
99
+ * must not keep the popup alive through fuzzy name/description hits.
100
+ */
101
+ function midPromptSkillTokenMatches(lowerToken: string, name: string, description: string): boolean {
102
+ if (SKILL_NAMESPACE.startsWith(lowerToken)) return true;
103
+ const lowerName = name.toLowerCase();
104
+ if (lowerToken.startsWith(SKILL_NAMESPACE)) {
105
+ if (scoreCommandTextMatch(lowerToken, lowerName) > 0) return true;
106
+ return description.length > 0 && scoreCommandTextMatch(lowerToken, description.toLowerCase()) > 0;
107
+ }
108
+ return (
109
+ lowerName.startsWith(SKILL_NAMESPACE) &&
110
+ skillBareNameBreakoutTier(lowerToken, lowerName.slice(SKILL_NAMESPACE.length)) > 0
111
+ );
112
+ }
113
+
114
+ /** The collapsed `/skill:` group row: one entry standing in for every skill. */
115
+ function skillNamespaceRow(skillCount: number): ComposerPickItem {
116
+ return {
117
+ id: 'command-skill-namespace',
118
+ name: SKILL_NAMESPACE,
119
+ description: `${skillCount} skill${skillCount === 1 ? '' : 's'}`,
120
+ kind: 'skill',
121
+ source: 'skill',
122
+ token: SKILL_NAMESPACE_TOKEN,
123
+ // Accepted without a trailing space so the popup reopens on the individual
124
+ // skills — oh-my-pi's namespace-row accept path.
125
+ insertWithoutSpace: true,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Collapse `skill:*` entries into a single `/skill:` namespace row while the
131
+ * typed prefix has not committed to the namespace (oh-my-pi's
132
+ * `collapseSkillNamespace`). A skill breaks out of the group only when its bare
133
+ * name matches the prefix at the beginning of the name or a hyphen-delimited
134
+ * segment, at a strictly stronger tier than every non-skill command name and
135
+ * alias; a tie keeps the popup command-only, and fuzzy-only skill hits never
136
+ * surface.
137
+ */
138
+ function collapseSkillNamespace(items: ComposerPickItem[], lowerPrefix: string): ComposerPickItem[] {
139
+ if (lowerPrefix.startsWith(SKILL_NAMESPACE)) return items;
140
+ const approachesNamespace = SKILL_NAMESPACE.startsWith(lowerPrefix);
141
+ let commandTier = 0;
142
+ if (!approachesNamespace) {
143
+ for (const item of items) {
144
+ const name = item.name.toLowerCase();
145
+ if (name.startsWith(SKILL_NAMESPACE)) continue;
146
+ commandTier = Math.max(commandTier, commandBreakoutTier(lowerPrefix, name));
147
+ for (const alias of item.aliases ?? []) {
148
+ commandTier = Math.max(commandTier, commandBreakoutTier(lowerPrefix, alias.toLowerCase()));
149
+ }
150
+ if (commandTier === 1000) break;
151
+ }
152
+ }
153
+
154
+ let skillCount = 0;
155
+ const rest = items.filter((item) => {
156
+ const name = item.name.toLowerCase();
157
+ if (!name.startsWith(SKILL_NAMESPACE)) return true;
158
+ skillCount += 1;
159
+ return (
160
+ !approachesNamespace &&
161
+ skillBareNameBreakoutTier(lowerPrefix, name.slice(SKILL_NAMESPACE.length)) > commandTier
162
+ );
163
+ });
164
+
165
+ if (skillCount === 0) return items;
166
+ if (!SKILL_NAMESPACE.startsWith(lowerPrefix)) return rest;
167
+ rest.push(skillNamespaceRow(skillCount));
168
+ return rest;
169
+ }
170
+
171
+ /** Prefix-match range for the rendered name, or null when nothing aligns. */
172
+ function prefixMatch(name: string, query: string): { start: number; end: number } | null {
173
+ if (query.length === 0) return null;
174
+ return name.toLowerCase().startsWith(query.toLowerCase()) ? { start: 0, end: query.length } : null;
175
+ }
176
+
177
+ /**
178
+ * Score one command item the way oh-my-pi's `buildSlashCommandCompletions`
179
+ * does: the stronger of the name score (skills also match on their bare name)
180
+ * and half the description subsequence score. When an alias outscores the
181
+ * primary name the alias becomes the surfaced token, so `/models` inserts
182
+ * `/models`.
183
+ */
184
+ function rankCommandItem(
185
+ item: ComposerPickItem,
186
+ lowerPrefix: string,
187
+ ): { item: ComposerPickItem; score: number; match: { start: number; end: number } | null } | null {
188
+ const name = item.name;
189
+ const lowerName = name.toLowerCase();
190
+ const isSkillCommand = lowerName.startsWith(SKILL_NAMESPACE);
191
+
192
+ const nameScore =
193
+ lowerPrefix.length === 0 && isSkillCommand
194
+ ? 950
195
+ : isSkillCommand
196
+ ? Math.max(
197
+ scoreCommandTextMatch(lowerPrefix, lowerName),
198
+ skillBareNameBreakoutTier(lowerPrefix, lowerName.slice(SKILL_NAMESPACE.length)),
199
+ )
200
+ : scoreCommandTextMatch(lowerPrefix, lowerName);
201
+
202
+ const lowerDesc = item.description.toLowerCase();
203
+ const descScore =
204
+ lowerDesc && subsequenceMatch(lowerPrefix, lowerDesc) ? subsequenceScore(lowerPrefix, lowerDesc) * 0.5 : 0;
205
+
206
+ const primaryScore = Math.max(nameScore, descScore);
207
+ let best = primaryScore > 0 ? { item, score: primaryScore, match: prefixMatch(name, lowerPrefix) } : undefined;
208
+
209
+ if (lowerPrefix.length > 0) {
210
+ for (const alias of item.aliases ?? []) {
211
+ if (alias.toLowerCase() === lowerName) continue;
212
+ const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
213
+ if (aliasScore === 0 || (best && aliasScore <= best.score)) continue;
214
+ best = {
215
+ item: { ...item, name: alias, token: `/${alias}` },
216
+ score: aliasScore,
217
+ match: prefixMatch(alias, lowerPrefix),
218
+ };
219
+ }
220
+ }
221
+
222
+ return best ?? null;
223
+ }
224
+
225
+ function rankCommands(items: ComposerPickItem[], lowerPrefix: string): ComposerMatchItem[] {
226
+ const ranked: Array<{ item: ComposerPickItem; score: number; match: { start: number; end: number } | null }> = [];
227
+ for (const item of items) {
228
+ const hit = rankCommandItem(item, lowerPrefix);
229
+ if (hit) ranked.push(hit);
230
+ }
231
+ // Equal scores keep list order — oh-my-pi falls back to usage, which the
232
+ // chamber does not track, then to stable registry order.
233
+ ranked.sort((a, b) => b.score - a.score);
234
+ return ranked.map((entry) => ({ ...entry.item, match: entry.match }));
235
+ }
236
+
237
+ /**
238
+ * Filter `/`-command items for the command-name phase. `midPrompt` restricts
239
+ * the pool to skills (oh-my-pi's mid-prompt lookup); otherwise skills collapse
240
+ * into the `/skill:` namespace row until the prefix commits to the namespace.
241
+ */
242
+ export function filterCommandItems(items: ComposerPickItem[], query: string, midPrompt = false): ComposerMatchItem[] {
243
+ const lowerPrefix = query.toLowerCase();
244
+
245
+ if (midPrompt) {
246
+ const skills = items.filter(
247
+ (item) =>
248
+ item.name.toLowerCase().startsWith(SKILL_NAMESPACE) &&
249
+ midPromptSkillTokenMatches(lowerPrefix, item.name, item.description),
250
+ );
251
+ return rankCommands(skills, lowerPrefix);
252
+ }
253
+
254
+ return rankCommands(collapseSkillNamespace(items, lowerPrefix), lowerPrefix);
255
+ }
256
+
257
+ /**
258
+ * Subcommand items for the `args` phase, mirroring oh-my-pi's declarative
259
+ * `buildArgumentCompletions`: a case-insensitive prefix match on the
260
+ * subcommand name, and nothing once the argument text itself contains a space.
261
+ */
262
+ export function commandArgumentItems(
263
+ items: ComposerPickItem[],
264
+ command: string,
265
+ query: string,
266
+ ): ComposerMatchItem[] {
267
+ if (query.includes(' ')) return [];
268
+
269
+ const lower = query.toLowerCase();
270
+ const lowerCommand = command.toLowerCase();
271
+ const owner = items.find(
272
+ (item) =>
273
+ item.name.toLowerCase() === lowerCommand ||
274
+ (item.aliases ?? []).some((alias) => alias.toLowerCase() === lowerCommand),
275
+ );
276
+ const subcommands: ComposerSubcommand[] = owner?.subcommands ?? [];
277
+ if (subcommands.length === 0) return [];
278
+
279
+ return subcommands
280
+ .filter((sub) => sub.name.toLowerCase().startsWith(lower))
281
+ .map((sub) => ({
282
+ id: `${owner?.id ?? command}-sub-${sub.name}`,
283
+ name: sub.name,
284
+ description: sub.description ?? sub.usage ?? '',
285
+ kind: 'command' as const,
286
+ source: 'command' as const,
287
+ token: sub.name,
288
+ match: prefixMatch(sub.name, query),
289
+ }));
290
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's `/` popup must match oh-my-pi's own editor, because the text
8
+ * it inserts is fed straight back to omp as a prompt. The expected values here
9
+ * were read off `@oh-my-pi/pi-tui`'s `CombinedAutocompleteProvider` driving the
10
+ * same command pool, so a regression in ranking, the `/skill:` namespace
11
+ * collapse, or the subcommand phase fails this file.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+
16
+ import type { ComposerPickItem } from '@/shared/types';
17
+ import { detectComposerTrigger, insertToken, tokenForItem } from '@/shared/lib/chat/composer/trigger';
18
+ import { filterComposerItems } from '@/shared/lib/chat/composer/filter';
19
+
20
+ function item(over: Partial<ComposerPickItem> & { name: string }): ComposerPickItem {
21
+ const isSkill = over.name.startsWith('skill:');
22
+ return {
23
+ id: `i-${over.name}`,
24
+ description: '',
25
+ kind: isSkill ? 'skill' : 'command',
26
+ source: isSkill ? 'skill' : 'command',
27
+ token: `/${over.name}`,
28
+ ...over,
29
+ };
30
+ }
31
+
32
+ const POOL: ComposerPickItem[] = [
33
+ item({ name: 'security', description: 'Plan, run, inspect, import, and compare security scans' }),
34
+ item({ name: 'model', description: 'Show current model selection', aliases: ['models'] }),
35
+ item({
36
+ name: 'fast',
37
+ description: 'Toggle fast mode',
38
+ subcommands: [
39
+ { name: 'on', description: 'Enable fast mode' },
40
+ { name: 'off', description: 'Disable fast mode' },
41
+ { name: 'status', description: 'Show fast mode status' },
42
+ ],
43
+ }),
44
+ item({ name: 'retry', description: 'Retry the last failed agent turn' }),
45
+ item({ name: 'rename', description: 'Rename the current session' }),
46
+ item({ name: 'compact', description: 'Compact the conversation' }),
47
+ item({ name: 'wt', description: 'Move this session into a new worktree', aliases: ['worktree'] }),
48
+ item({ name: 'skill:handoff', description: 'Compact the current conversation into a handoff document' }),
49
+ item({ name: 'skill:diagnose', description: 'Disciplined diagnosis loop for hard bugs' }),
50
+ item({ name: 'skill:research-last30days', description: 'Research last 30 days' }),
51
+ item({ name: 'skill:zoom-out', description: 'Zoom out' }),
52
+ ];
53
+
54
+ /** Tokens the popup offers for a draft, as omp's provider would order them. */
55
+ function tokensFor(text: string, caret = text.length): string[] {
56
+ const trigger = detectComposerTrigger(text, caret);
57
+ return trigger ? filterComposerItems(POOL, trigger).map((m) => m.token) : [];
58
+ }
59
+
60
+ /** What the popup offers after accepting `pick` from `text`. */
61
+ function acceptThenRequery(text: string, pick: string): { value: string; requery: string[] } {
62
+ const trigger = detectComposerTrigger(text, text.length);
63
+ if (!trigger) throw new Error(`no trigger for ${text}`);
64
+ const match = filterComposerItems(POOL, trigger).find((m) => m.token === pick || m.name === pick);
65
+ if (!match) throw new Error(`no match ${pick} for ${text}`);
66
+ const next = insertToken(text, trigger, tokenForItem(match));
67
+ return { value: next.value, requery: tokensFor(next.value, next.caret) };
68
+ }
69
+
70
+ describe('composer slash commands', () => {
71
+ test('skills collapse into one namespace row while the prefix is generic', () => {
72
+ expect(tokensFor('/')).toEqual([
73
+ '/skill:',
74
+ '/security',
75
+ '/model',
76
+ '/fast',
77
+ '/retry',
78
+ '/rename',
79
+ '/compact',
80
+ '/wt',
81
+ ]);
82
+ });
83
+
84
+ test('a skill breaks out only on a stronger match than every command', () => {
85
+ // `handoff` wins the namespace tier; `zoom-out` matches on a later segment.
86
+ expect(tokensFor('/han')).toEqual(['/skill:handoff', '/retry', '/compact', '/wt']);
87
+ expect(tokensFor('/z')).toEqual(['/skill:zoom-out']);
88
+ // A tie leaves the popup command-only.
89
+ expect(tokensFor('/re')).toEqual(['/retry', '/rename', '/worktree', '/security', '/model']);
90
+ });
91
+
92
+ test('an alias outranking its command name is what gets inserted', () => {
93
+ expect(tokensFor('/models')).toEqual(['/models']);
94
+ expect(tokensFor('/worktree')).toEqual(['/worktree']);
95
+ });
96
+
97
+ test('a slash token with no command match offers nothing', () => {
98
+ // `/tmp/fo` is a path, not a command: an empty popup would just cover the composer.
99
+ expect(tokensFor('/zzz')).toEqual([]);
100
+ expect(tokensFor('/tmp/fo')).toEqual([]);
101
+ expect(tokensFor('/retry ')).toEqual([]);
102
+ });
103
+
104
+ test('a command with subcommands switches to the argument phase', () => {
105
+ expect(tokensFor('/fast ')).toEqual(['on', 'off', 'status']);
106
+ expect(tokensFor('/fast o')).toEqual(['on', 'off']);
107
+ // Past the subcommand, or with a space inside the argument, completion stops.
108
+ expect(tokensFor('/fast on ')).toEqual([]);
109
+ expect(tokensFor('/fast o')).toEqual([]);
110
+ });
111
+
112
+ test('accepting a command reopens on its subcommands, not an empty list', () => {
113
+ expect(acceptThenRequery('/fast', '/fast')).toEqual({ value: '/fast ', requery: ['on', 'off', 'status'] });
114
+ expect(acceptThenRequery('/models', '/models')).toEqual({ value: '/models ', requery: [] });
115
+ });
116
+
117
+ test('accepting the namespace row keeps the caret on the namespace', () => {
118
+ // No trailing space: the popup must reopen on the individual skills.
119
+ expect(acceptThenRequery('/sk', '/skill:')).toEqual({
120
+ value: '/skill:',
121
+ requery: ['/skill:handoff', '/skill:diagnose', '/skill:research-last30days', '/skill:zoom-out'],
122
+ });
123
+ expect(acceptThenRequery('/skill:', '/skill:handoff')).toEqual({
124
+ value: '/skill:handoff ',
125
+ requery: [],
126
+ });
127
+ });
128
+
129
+ test('a mid-prompt slash token only ever surfaces skills', () => {
130
+ expect(tokensFor('fix bug /skill:han')).toEqual(['/skill:handoff']);
131
+ expect(tokensFor('fix bug /han')).toEqual(['/skill:handoff']);
132
+ // `/fast` mid-prose is not a skill, so nothing opens.
133
+ expect(tokensFor('prose /fast')).toEqual([]);
134
+ });
135
+
136
+ test('a leading slash survives indentation', () => {
137
+ expect(tokensFor(' /fa')).toEqual(['/fast', '/retry']);
138
+ });
139
+ });
140
+
141
+ describe('composer slash trigger', () => {
142
+ test('reports the phase and the span each accept replaces', () => {
143
+ expect(detectComposerTrigger('/fast', 5)).toMatchObject({ kind: 'command', phase: 'name', query: 'fast', replaceFrom: 0 });
144
+ expect(detectComposerTrigger('/fast o', 7)).toMatchObject({ kind: 'command', phase: 'args', query: 'o', command: 'fast', replaceFrom: 6 });
145
+ // Earlier arguments survive acceptance: the replacement span is the
146
+ // argument text (oh-my-pi's `beforePrefix`), not the whole invocation.
147
+ expect(detectComposerTrigger('/security plan s', 16)).toMatchObject({ phase: 'args', query: 'plan s', replaceFrom: 10 });
148
+ // ...and a space inside the argument ends completion, as in oh-my-pi.
149
+ expect(tokensFor('/security plan s')).toEqual([]);
150
+ });
151
+
152
+ test('an @mention trigger keeps its own span', () => {
153
+ expect(detectComposerTrigger('@file:src/a', 11)).toMatchObject({ kind: 'mention', query: 'file:src/a', start: 0 });
154
+ expect(detectComposerTrigger('see @sonic', 10)).toMatchObject({ kind: 'mention', query: 'sonic', start: 4 });
155
+ expect(detectComposerTrigger('foo@bar', 7)).toBeNull();
156
+ });
157
+ });
@@ -1,8 +1,10 @@
1
- import type { ComposerMatchItem, ComposerPickItem } from '@/shared/types';
1
+ import type { ComposerMatchItem, ComposerPickItem, ComposerTrigger } from '@/shared/types';
2
+ import { filterCommandItems, commandArgumentItems } from '@/shared/lib/chat/composer/commands';
2
3
 
3
4
  /** Detects the `file:` query prefix (case-insensitive) that scopes to files. */
4
5
  const FILE_QUERY_PREFIX_RE = /^file:/i;
5
6
 
7
+ /** Rank tiers for `@` mentions (chamber-only; omp's file completion is path-based). */
6
8
  interface RankedMatch {
7
9
  tier: number;
8
10
  index: number;
@@ -13,7 +15,7 @@ interface RankedMatch {
13
15
  * Score a single item against a query. Tier order: exact name > name
14
16
  * startsWith > name includes > description includes. Returns null on no match.
15
17
  */
16
- export function rankComposerItem(
18
+ function rankComposerItem(
17
19
  name: string,
18
20
  description: string,
19
21
  query: string,
@@ -35,13 +37,16 @@ export function rankComposerItem(
35
37
  return null;
36
38
  }
37
39
 
38
- /** Filter items by query, ranked by tier then original order. */
39
- export function filterComposerItems(items: ComposerPickItem[], query: string): ComposerMatchItem[] {
40
+ /**
41
+ * Filter `@` mention items by query, ranked by tier then original order.
42
+ *
43
+ * `file:` scopes the pool to files and ranks on the path remainder, so
44
+ * `@file:composer` finds `app/lib/chat/composer/client.ts`. Bare `@` keeps the
45
+ * full agents + files pool.
46
+ */
47
+ function filterMentionItems(items: ComposerPickItem[], query: string): ComposerMatchItem[] {
40
48
  const trimmed = query.trim();
41
49
 
42
- // `file:` scopes the pool to files and ranks on the path remainder, so
43
- // `@file:composer` finds `app/lib/chat/composer/client.ts`. Bare `@` keeps
44
- // the full agents + files pool.
45
50
  const fileQuery = FILE_QUERY_PREFIX_RE.test(trimmed);
46
51
  const pool = fileQuery ? items.filter((item) => item.source === 'file') : items;
47
52
  const rankingQuery = fileQuery ? trimmed.replace(FILE_QUERY_PREFIX_RE, '').trim() : trimmed;
@@ -60,3 +65,12 @@ export function filterComposerItems(items: ComposerPickItem[], query: string): C
60
65
 
61
66
  return ranked.map((entry) => ({ ...pool[entry.index], match: entry.match }));
62
67
  }
68
+
69
+ /** Dispatch on the trigger: mentions keep their own ranking, commands use omp's. */
70
+ export function filterComposerItems(items: ComposerPickItem[], trigger: ComposerTrigger): ComposerMatchItem[] {
71
+ if (trigger.kind === 'mention') return filterMentionItems(items, trigger.query);
72
+ if (trigger.phase === 'args' && trigger.command) {
73
+ return commandArgumentItems(items, trigger.command, trigger.query);
74
+ }
75
+ return filterCommandItems(items, trigger.query, trigger.midPrompt === true);
76
+ }
@@ -3,32 +3,121 @@ import type { ComposerPickItem, ComposerTrigger } from '@/shared/types';
3
3
  /** Chars that may legally precede an `@` agent trigger (besides string start). */
4
4
  const AT_PRECEDING_RE = /[\s([{]/;
5
5
 
6
+ /** oh-my-pi's `SKILL_NAMESPACE`: skills are addressed as `/skill:<name>`. */
7
+ export const SKILL_NAMESPACE = 'skill:';
8
+
9
+ /** The collapsed namespace row's token (no trailing space on accept). */
10
+ export const SKILL_NAMESPACE_TOKEN = `/${SKILL_NAMESPACE}`;
11
+
12
+ /**
13
+ * Index of a `/` that starts the draft, ignoring leading whitespace —
14
+ * oh-my-pi's `findLeadingSlashCommandStart`.
15
+ */
16
+ function leadingSlashStart(text: string): number | null {
17
+ const trimmed = text.trimStart();
18
+ if (!trimmed.startsWith('/')) return null;
19
+ return text.length - trimmed.length;
20
+ }
21
+
22
+ /**
23
+ * Index of a `/` that opens the trailing token (`/(?:^|\s)\/([^\s/]*)$`) —
24
+ * oh-my-pi's `findTrailingSlashCommandStart`.
25
+ */
26
+ function trailingSlashStart(text: string): number | null {
27
+ const match = /(?:^|\s)\/([^\s/]*)$/.exec(text);
28
+ if (!match || match.index === undefined) return null;
29
+ const slashOffset = match[0].indexOf('/');
30
+ return match.index + slashOffset;
31
+ }
32
+
33
+ /**
34
+ * Whether prose precedes the slash token: any earlier non-blank line, or
35
+ * non-whitespace before it on this line — oh-my-pi's
36
+ * `hasPromptTextBeforeSlash`. Such a token is a mid-prompt skill lookup,
37
+ * which only ever surfaces skills.
38
+ */
39
+ function hasPromptTextBeforeSlash(lines: readonly string[], line: number, before: string, slashStart: number): boolean {
40
+ for (let i = 0; i < line; i += 1) {
41
+ if ((lines[i] ?? '').trim() !== '') return true;
42
+ }
43
+ return before.slice(0, slashStart).trim() !== '';
44
+ }
45
+
6
46
  /**
7
47
  * Detect an `@`-mention (files + agents) or `/`-command trigger at the given
8
- * caret position.
48
+ * caret position, mirroring oh-my-pi's `CombinedAutocompleteProvider`:
9
49
  *
10
- * Scans backwards from `caret - 1`: a whitespace/newline before any trigger
11
- * char aborts the scan. `/` is only a trigger at index 0; `@` is a trigger at
12
- * index 0 or when preceded by whitespace, `(`, `[`, or `{`. The query is the
13
- * run of non-whitespace characters between the trigger char and the caret, so
14
- * `foo@bar` and `a/b` (query containing another trigger char) never match.
50
+ * - `/` starts a command at the draft start (leading whitespace allowed) or as
51
+ * a trailing token after whitespace on the caret's line. A `/` in the middle
52
+ * of a token (`a/b`) never matches.
53
+ * - Once `<command> ` is typed, the popup switches to the `args` phase, which
54
+ * completes that command's declarative subcommands.
55
+ * - `@` is a trigger at index 0 or when preceded by whitespace, `(`, `[`, `{`.
56
+ *
57
+ * `lines` is the whole draft split on newlines and `line` the caret's line
58
+ * index, because the mid-prompt rule inspects earlier lines.
15
59
  */
16
60
  export function detectComposerTrigger(text: string, caret: number): ComposerTrigger | null {
17
61
  const end = Math.max(0, Math.min(caret, text.length));
62
+ const before = text.slice(0, end);
63
+ // Mid-prompt detection needs the earlier lines, and every rule below is
64
+ // scoped to the caret's own line.
65
+ const lines = text.split('\n');
66
+ const line = (before.match(/\n/g) ?? []).length;
67
+ const lineStart = before.lastIndexOf('\n') + 1;
68
+ const beforeOnLine = before.slice(lineStart);
18
69
 
19
- for (let i = end - 1; i >= 0; i--) {
20
- const ch = text[i];
70
+ const leadStart = leadingSlashStart(beforeOnLine);
71
+ const trailStart = trailingSlashStart(beforeOnLine);
72
+ const midPrompt = trailStart !== null && hasPromptTextBeforeSlash(lines, line, beforeOnLine, trailStart);
73
+ const leadIsMidPrompt = leadStart !== null && hasPromptTextBeforeSlash(lines, line, beforeOnLine, leadStart);
21
74
 
22
- if (/\s/.test(ch)) return null;
75
+ const slashStart = midPrompt ? trailStart : leadIsMidPrompt ? null : leadStart;
23
76
 
24
- if (ch === '/' || ch === '@') {
25
- if (ch === '/') {
26
- if (i !== 0) return null;
27
- return { kind: 'command', query: text.slice(i + 1, end), start: i, end };
28
- }
77
+ if (slashStart !== null) {
78
+ const commandText = beforeOnLine.slice(slashStart);
79
+ const spaceIndex = commandText.indexOf(' ');
80
+
81
+ if (spaceIndex === -1) {
82
+ return {
83
+ kind: 'command',
84
+ query: commandText.slice(1),
85
+ start: lineStart + slashStart,
86
+ end,
87
+ phase: 'name',
88
+ replaceFrom: lineStart + slashStart,
89
+ ...(midPrompt ? { midPrompt: true } : {}),
90
+ };
91
+ }
92
+
93
+ if (!midPrompt) {
94
+ // Argument phase: everything after `<command> ` completes that command's
95
+ // subcommands. oh-my-pi anchors the replacement on the argument text
96
+ // only, so earlier arguments survive acceptance.
97
+ const argStart = lineStart + slashStart + spaceIndex + 1;
98
+ return {
99
+ kind: 'command',
100
+ query: before.slice(argStart),
101
+ start: lineStart + slashStart,
102
+ end,
103
+ phase: 'args',
104
+ replaceFrom: argStart,
105
+ command: commandText.slice(1, spaceIndex),
106
+ };
107
+ }
108
+ }
109
+
110
+ // `@` mentions scan back to the token start; whitespace aborts the scan so
111
+ // `foo@bar` never triggers.
112
+ for (let i = beforeOnLine.length - 1; i >= 0; i--) {
113
+ const ch = beforeOnLine[i];
114
+
115
+ if (/\s/.test(ch)) return null;
29
116
 
30
- if (i === 0 || AT_PRECEDING_RE.test(text[i - 1])) {
31
- return { kind: 'mention', query: text.slice(i + 1, end), start: i, end };
117
+ if (ch === '@') {
118
+ if (i === 0 || AT_PRECEDING_RE.test(beforeOnLine[i - 1])) {
119
+ const at = lineStart + i;
120
+ return { kind: 'mention', query: before.slice(at + 1), start: at, end, phase: 'name', replaceFrom: at };
32
121
  }
33
122
  return null;
34
123
  }
@@ -37,9 +126,9 @@ export function detectComposerTrigger(text: string, caret: number): ComposerTrig
37
126
  return null;
38
127
  }
39
128
 
40
- /** Insertion text for an item: its token followed by a single trailing space. */
129
+ /** Insertion text for an item: its token, plus a trailing space unless suppressed. */
41
130
  export function tokenForItem(item: ComposerPickItem): string {
42
- return `${item.token} `;
131
+ return item.insertWithoutSpace ? item.token : `${item.token} `;
43
132
  }
44
133
 
45
134
  /** Splice `token` over the trigger span in `value`, returning the new value and caret. */
@@ -48,6 +137,6 @@ export function insertToken(
48
137
  trigger: ComposerTrigger,
49
138
  token: string,
50
139
  ): { value: string; caret: number } {
51
- const next = value.slice(0, trigger.start) + token + value.slice(trigger.end);
52
- return { value: next, caret: trigger.start + token.length };
140
+ const next = value.slice(0, trigger.replaceFrom) + token + value.slice(trigger.end);
141
+ return { value: next, caret: trigger.replaceFrom + token.length };
53
142
  }