ompchamber 3.3.2 → 3.4.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 (63) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +275 -268
  3. package/dist/client/static/js/{index-e3axkbk1.js → index-h05z90mw.js} +581 -581
  4. package/package.json +1 -1
  5. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  6. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  7. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  8. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  9. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  10. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  11. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  12. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  13. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  14. package/src/client/hooks/chat/btw/index.ts +7 -73
  15. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  16. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  17. package/src/client/hooks/chat/btw/mode.ts +16 -76
  18. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  19. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  20. package/src/client/hooks/chat/timeline/send.ts +8 -1
  21. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  22. package/src/server/lib/assets/font-css.server.test.ts +87 -0
  23. package/src/server/lib/assets/font-css.server.ts +46 -20
  24. package/src/server/lib/assets/font-faces.ts +69 -0
  25. package/src/server/lib/assets/fonts.server.ts +27 -4
  26. package/src/server/lib/btw/child.server.test.ts +68 -0
  27. package/src/server/lib/btw/child.server.ts +17 -16
  28. package/src/server/lib/btw/frames.test.ts +3 -3
  29. package/src/server/lib/btw/frames.ts +1 -1
  30. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  31. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  32. package/src/server/lib/btw/prompt.ts +7 -11
  33. package/src/server/lib/btw/registry.server.ts +1 -9
  34. package/src/server/lib/btw/reply.ts +54 -0
  35. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  36. package/src/server/lib/btw/runtime.server.ts +77 -86
  37. package/src/server/lib/btw/schema.server.test.ts +84 -0
  38. package/src/server/lib/btw/schema.server.ts +8 -0
  39. package/src/server/lib/btw/service.server.ts +47 -78
  40. package/src/server/lib/btw/store.server.ts +9 -20
  41. package/src/server/lib/bundler/build-errors.server.ts +8 -3
  42. package/src/server/lib/bundler/css.ts +12 -61
  43. package/src/server/lib/omp/rpc/constants.ts +10 -0
  44. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  45. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  46. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  47. package/src/server/lib/omp/session/messages.ts +27 -2
  48. package/src/server/plugins/ssr.ts +4 -4
  49. package/src/server/plugins/static.ts +14 -10
  50. package/src/server/routes/agent/command.ts +9 -1
  51. package/src/server/routes/btw/topics.ts +2 -59
  52. package/src/shared/lib/chat/btw/client.ts +0 -30
  53. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  54. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  55. package/src/shared/lib/chat/notice-text.ts +33 -0
  56. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  57. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  58. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  59. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  60. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  61. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  62. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  63. package/src/shared/types/btw.ts +13 -34
@@ -235,6 +235,32 @@ export async function loadSessionModel(filePath: string): Promise<{ provider: st
235
235
  * `thinking_level_change` entries (omp records the level string, e.g.
236
236
  * "off" | "minimal" | "low" | "medium" | "high" | "max"). */
237
237
  export async function loadSessionThinkingLevel(filePath: string): Promise<string | undefined> {
238
+ return readThinkingSelector(filePath, (record) => normalizeThinkingLevel(record.thinkingLevel));
239
+ }
240
+
241
+ /**
242
+ * The thinking SELECTOR a session is configured with — `auto` when automatic
243
+ * per-turn classification is on, else the concrete level.
244
+ *
245
+ * omp writes both fields on one entry (`thinkingLevel` = the provisional
246
+ * concrete effort, `configured` = the selector) and restores with
247
+ * `parseConfiguredThinkingLevel(configured) ?? parseThinkingLevel(level)`, so a
248
+ * reader that takes `thinkingLevel` alone turns an `auto` session into a pinned
249
+ * one. Anything spawning a child that must classify its turns the way the
250
+ * session does has to read this, not the concrete level.
251
+ */
252
+ export async function loadSessionThinkingSelector(filePath: string): Promise<string | undefined> {
253
+ return readThinkingSelector(filePath, (record) =>
254
+ typeof record.configured === 'string' && record.configured
255
+ ? record.configured
256
+ : normalizeThinkingLevel(record.thinkingLevel),
257
+ );
258
+ }
259
+
260
+ async function readThinkingSelector(
261
+ filePath: string,
262
+ read: (record: Record<string, unknown>) => string,
263
+ ): Promise<string | undefined> {
238
264
  try {
239
265
  const file = Bun.file(filePath);
240
266
  if ((await file.stat()).size > MAX_SESSION_LOAD_BYTES) return undefined;
@@ -247,8 +273,7 @@ export async function loadSessionThinkingLevel(filePath: string): Promise<string
247
273
  let last: string | undefined;
248
274
  for (const record of records) {
249
275
  if (record?.type !== 'thinking_level_change') continue;
250
- // A change entry always yields a display level: null/unset → "off".
251
- last = normalizeThinkingLevel(record.thinkingLevel);
276
+ last = read(record);
252
277
  }
253
278
  return last;
254
279
  } catch {
@@ -135,10 +135,10 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
135
135
  const themeStyle = `<style id="${THEME_STYLE_ELEMENT_ID}">${themeStyleSheet()}</style>`;
136
136
  // The font faces are a stylesheet the bundler cannot carry: Bun resolves every
137
137
  // local `url()` in CSS, so a bundled face is either base64 or an absolute
138
- // filesystem path. `lib/bundler/css.ts` lifts the blocks out and the route
139
- // below serves them, which is why the link is injected here — a
140
- // `<link href="/fonts.css">` in index.html would be resolved by the HTML
141
- // loader and fail the same way.
138
+ // filesystem path. `lib/bundler/css.ts` strips the blocks out of the bundle
139
+ // and `lib/assets/font-css.server.ts` re-declares them on the route below,
140
+ // which is why the link is injected here — a `<link href="/fonts.css">` in
141
+ // index.html would be resolved by the HTML loader and fail the same way.
142
142
  const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
143
143
 
144
144
  // Bun's dev asset URLs move to this server's prefix so the proxy above can add
@@ -2,7 +2,7 @@ import { extname, join, normalize, resolve, sep } from 'path';
2
2
 
3
3
  import { notModified } from '@/server/lib/assets/conditional.server';
4
4
  import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
5
- import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
5
+ import { FONT_ROUTE_PREFIX, packageDir } from '@/server/lib/assets/fonts.server';
6
6
 
7
7
  /**
8
8
  * File serving for `public/` and the font routes.
@@ -21,12 +21,16 @@ import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server
21
21
  * catch-all, which would shadow this app's SSR route. Exposing one lookup
22
22
  * function lets the web catch-all try a file first and fall back to the shell.
23
23
  */
24
- // Resolved from the package root, not the cwd: `serve --prod` may run the built
25
- // server from anywhere, and a cwd-relative `public` would not exist — every
26
- // icon, the manifest and the service worker would 404.
27
- const PUBLIC_ROOT = resolve(packageRoot(), 'public');
24
+ // Resolved from the package's own directory, not the cwd: `serve --prod` may
25
+ // run the built server from anywhere, and a cwd-relative `public` would not
26
+ // exist — every icon, the manifest and the service worker would 404. The
27
+ // package, NOT `packageRoot()`: in a hoisted install the first root holding
28
+ // `node_modules` is the parent, where `public/` and `dist/client/` do not exist,
29
+ // so the lookup would fall through to the shell and answer an image request with
30
+ // HTML.
31
+ const PUBLIC_ROOT = resolve(packageDir(), 'public');
28
32
  /** Where `scripts/build-client.ts` writes the production bundle and its chunks. */
29
- const CLIENT_ROOT = resolve(packageRoot(), 'dist', 'client');
33
+ const CLIENT_ROOT = resolve(packageDir(), 'dist', 'client');
30
34
 
31
35
  const IMMUTABLE = 'public, max-age=31536000, immutable';
32
36
  const SHORT = 'public, max-age=3600';
@@ -141,12 +145,12 @@ export async function tryServeStatic(pathname: string, request?: Request): Promi
141
145
  const filePath = resolveWithin(CLIENT_ROOT, pathname);
142
146
  return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
143
147
  }
144
- // The extracted `@font-face` stylesheet and the files it names. Bun's CSS
148
+ // The assembled `@font-face` stylesheet and the files it names. Bun's CSS
145
149
  // loader cannot carry either — every local `url()` is resolved, so a bundled
146
150
  // face is base64 or an absolute path — which is why they are served rather
147
- // than emitted. Both live outside `public/`: the stylesheet is assembled from
148
- // the faces `lib/bundler/css.ts` wrote, and the files come from the installed
149
- // packages.
151
+ // than emitted. Both live outside `public/`: the stylesheet re-declares the
152
+ // faces `lib/bundler/css.ts` stripped, read from the source stylesheets, and
153
+ // the files come from the installed packages.
150
154
  //
151
155
  // Neither is given a conditional here: the stylesheet is `no-store` (nothing
152
156
  // is ever stored to revalidate) and a font file is `immutable` (the browser
@@ -5,8 +5,9 @@ import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type Agen
5
5
  import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
6
6
  import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
7
  import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
8
- import { OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
8
+ import { CONVERSATION_MOVING_COMMANDS, OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
9
9
  import { rpcErrorResponse } from '@/server/lib/omp/rpc/errors';
10
+ import { assertBtwIdle } from '@/server/lib/btw/service.server';
10
11
 
11
12
  // POST /api/agent/:sessionId — send a command to an existing session (or spawn
12
13
  // it lazily). Mirrors omp-web's /api/agent/[id].
@@ -28,6 +29,13 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
28
29
  // default. omp has no RPC to change the mode after spawn.
29
30
  const explicitMode = isApprovalMode(body.accessMode) ? body.accessMode : null;
30
31
 
32
+ // A command that moves the conversation is refused while a side question is
33
+ // in flight on this session — the question's snapshot describes the
34
+ // transcript this command would replace (omp blocks the same operations).
35
+ if (CONVERSATION_MOVING_COMMANDS.has(body.type)) {
36
+ await assertBtwIdle(sessionId, body.type.replace(/_/g, ' '));
37
+ }
38
+
31
39
  // Fast path: already-running session.
32
40
  const existing = getRpcSession(sessionId);
33
41
  if (existing?.isAlive()) {
@@ -14,25 +14,15 @@
14
14
 
15
15
  import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
16
16
  import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
17
- import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
18
17
  import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
19
18
  import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
20
- import {
21
- abortBtw,
22
- askBtw,
23
- getBtwState,
24
- promoteBtw,
25
- removeBtw,
26
- respondBtwDialog,
27
- setBtwApprovalMode,
28
- setBtwModel,
29
- setBtwThinkingLevel,
30
- } from '@/server/lib/btw/service.server';
19
+ import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
31
20
 
32
21
  /** Actions that can reject for a reason the client should show as-is. */
33
22
  const STATUS_BY_CODE: Record<string, number> = {
34
23
  btw_busy: 409,
35
24
  btw_stale: 409,
25
+ btw_multi_turn: 409,
36
26
  session_not_found: 404,
37
27
  btw_topic_not_found: 404,
38
28
  };
@@ -71,20 +61,6 @@ function readTextFiles(raw: unknown): AttachedTextFileData[] {
71
61
  });
72
62
  }
73
63
 
74
- /**
75
- * The response body for one dialog, in the shape omp's RPC accepts: an answer
76
- * value, a confirmation, or a cancellation. Anything else is refused rather
77
- * than forwarded — a malformed response would leave the child blocked.
78
- */
79
- function readDialogResponse(raw: unknown): Record<string, unknown> | null {
80
- if (!raw || typeof raw !== 'object') return null;
81
- const response = raw as { value?: unknown; confirmed?: unknown; cancelled?: unknown };
82
- if (response.cancelled === true) return { cancelled: true };
83
- if (typeof response.confirmed === 'boolean') return { confirmed: response.confirmed };
84
- if (typeof response.value === 'string') return { value: response.value };
85
- return null;
86
- }
87
-
88
64
  export async function action({ params, request }: ActionFunctionArgs) {
89
65
  const { sessionId } = params;
90
66
  if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
@@ -100,8 +76,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
100
76
  const question = typeof body?.question === 'string' ? body.question : '';
101
77
  const parsed = readImages(body?.images);
102
78
  if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
103
- const provider = typeof body?.provider === 'string' ? body.provider : '';
104
- const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
105
79
  return json({
106
80
  success: true,
107
81
  data: await askBtw(sessionId, {
@@ -109,9 +83,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
109
83
  question,
110
84
  images: parsed.images,
111
85
  textFiles: readTextFiles(body?.textFiles),
112
- ...(provider && modelId ? { model: { provider, id: modelId } } : {}),
113
- ...(typeof body?.thinkingLevel === 'string' ? { thinkingLevel: body.thinkingLevel } : {}),
114
- ...(isApprovalMode(body?.accessMode) ? { approvalMode: body.accessMode } : {}),
115
86
  }),
116
87
  });
117
88
  }
@@ -119,34 +90,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
119
90
  if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
120
91
  return json({ success: true, data: await abortBtw(sessionId, topicId) });
121
92
  }
122
- case 'set_model': {
123
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
124
- const provider = typeof body?.provider === 'string' ? body.provider : '';
125
- const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
126
- if (!provider || !modelId) return json({ error: 'provider and modelId are required', code: 'model_required' }, { status: 400 });
127
- return json({ success: true, data: await setBtwModel(sessionId, topicId, provider, modelId) });
128
- }
129
- case 'set_thinking_level': {
130
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
131
- const level = typeof body?.level === 'string' ? body.level : '';
132
- if (!level) return json({ error: 'level is required', code: 'level_required' }, { status: 400 });
133
- return json({ success: true, data: await setBtwThinkingLevel(sessionId, topicId, level) });
134
- }
135
- case 'set_access_mode': {
136
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
137
- if (!isApprovalMode(body?.accessMode)) {
138
- return json({ error: 'accessMode must be always-ask, write or yolo', code: 'invalid_access_mode' }, { status: 400 });
139
- }
140
- return json({ success: true, data: await setBtwApprovalMode(sessionId, topicId, body.accessMode) });
141
- }
142
- case 'dialog_response': {
143
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
144
- const id = typeof body?.id === 'string' ? body.id : '';
145
- if (!id) return json({ error: 'id is required', code: 'dialog_required' }, { status: 400 });
146
- const response = readDialogResponse(body?.response);
147
- if (!response) return json({ error: 'a response value is required', code: 'dialog_required' }, { status: 400 });
148
- return json({ success: true, data: await respondBtwDialog(sessionId, topicId, id, response) });
149
- }
150
93
  case 'promote': {
151
94
  if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
152
95
  return json({ success: true, data: await promoteBtw(sessionId, topicId) });
@@ -15,7 +15,6 @@
15
15
 
16
16
  import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
17
17
  import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
18
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
18
  import { connectEvents } from '@/shared/lib/chat/omp/sse';
20
19
  import { connectSocket } from '@/shared/lib/chat/omp/socket';
21
20
  import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
@@ -74,11 +73,6 @@ export interface AskBtwInput {
74
73
  images?: AgentImage[];
75
74
  /** Text files inlined for this question (read at attach time by the composer). */
76
75
  textFiles?: AttachedTextFileData[];
77
- /** Composer picks for a FIRST question — a topic that does not exist yet has
78
- * no row to hold them, so they ride the ask and reach the spawn. */
79
- model?: { provider: string; id: string };
80
- thinkingLevel?: string;
81
- approvalMode?: ApprovalMode;
82
76
  }
83
77
 
84
78
  export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
@@ -88,9 +82,6 @@ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<B
88
82
  ...(input.topicId ? { topicId: input.topicId } : {}),
89
83
  ...(input.images?.length ? { images: input.images } : {}),
90
84
  ...(input.textFiles?.length ? { textFiles: input.textFiles } : {}),
91
- ...(input.model ? { provider: input.model.provider, modelId: input.model.id } : {}),
92
- ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
93
- ...(input.approvalMode ? { accessMode: input.approvalMode } : {}),
94
85
  });
95
86
  }
96
87
 
@@ -98,27 +89,6 @@ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<Bt
98
89
  return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
99
90
  }
100
91
 
101
- export function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
102
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_model', topicId, provider, modelId });
103
- }
104
-
105
- export function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
106
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_thinking_level', topicId, level });
107
- }
108
-
109
- export function setBtwAccessMode(sessionId: string, topicId: string, accessMode: ApprovalMode): Promise<BtwState> {
110
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_access_mode', topicId, accessMode });
111
- }
112
-
113
- export function respondBtwDialog(
114
- sessionId: string,
115
- topicId: string,
116
- id: string,
117
- response: { value: string } | { confirmed: boolean } | { cancelled: true },
118
- ): Promise<BtwState> {
119
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'dialog_response', topicId, id, response });
120
- }
121
-
122
92
  export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
123
93
  return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
124
94
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * The TUI-only guard. Two failure modes matter and both are silent:
6
+ *
7
+ * - a name omp DOES implement over RPC gets refused, so a working command stops
8
+ * working in the chamber. That is why the table is asserted against omp's own
9
+ * registry rather than trusted, and why the predicate is name-only.
10
+ * - a name omp does NOT implement slips through, which is the bug this module
11
+ * exists for: the prompt reaches the model as literal text and it improvises a
12
+ * turn around the string.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { isTuiOnlySlashCommand, slashCommandName, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
18
+
19
+ describe('slashCommandName', () => {
20
+ test('reads the token omp would dispatch on', () => {
21
+ expect(slashCommandName('/plan')).toBe('plan');
22
+ expect(slashCommandName('/PLAN')).toBe('plan');
23
+ // Arguments, colon form, and leading whitespace all resolve to the name.
24
+ expect(slashCommandName('/move /tmp/x')).toBe('move');
25
+ expect(slashCommandName('/force:write')).toBe('force');
26
+ expect(slashCommandName(' /clear')).toBe('clear');
27
+ });
28
+
29
+ test('a slash that is not a command is not a name', () => {
30
+ expect(slashCommandName('fix a/b')).toBeNull();
31
+ expect(slashCommandName('see /tmp/foo')).toBeNull();
32
+ expect(slashCommandName('prose\n/plan')).toBeNull();
33
+ expect(slashCommandName('/')).toBeNull();
34
+ expect(slashCommandName('')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('isTuiOnlySlashCommand', () => {
39
+ test('refuses the commands omp only implements in its TUI', () => {
40
+ for (const command of ['/plan', '/clear', '/new', '/login', '/hotkeys', '/tree', '/resume', '/quit', '/exit']) {
41
+ expect(isTuiOnlySlashCommand(command)).toBe(true);
42
+ }
43
+ // Aliases count: omp resolves these to the same TUI-only entry.
44
+ expect(isTuiOnlySlashCommand('/providers')).toBe(true);
45
+ expect(isTuiOnlySlashCommand('/q')).toBe(true);
46
+ expect(isTuiOnlySlashCommand('/status')).toBe(true);
47
+ expect(isTuiOnlySlashCommand('/rewind')).toBe(true);
48
+ });
49
+
50
+ test('lets every command omp answers over RPC through', () => {
51
+ // Sampled from the 42 entries carrying a text-mode `handle`; a false
52
+ // positive here silently breaks a working command.
53
+ for (const command of [
54
+ '/model',
55
+ '/context',
56
+ '/usage',
57
+ '/compact',
58
+ '/todo',
59
+ '/mcp list',
60
+ '/rename x',
61
+ '/move /tmp',
62
+ '/session info',
63
+ '/skill:diagnose',
64
+ '/review',
65
+ ]) {
66
+ expect(isTuiOnlySlashCommand(command)).toBe(false);
67
+ }
68
+ });
69
+
70
+ test('does NOT refuse a command the chamber answers itself', () => {
71
+ // `/btw` is TUI-only in omp, but the chamber owns it: the composer opens
72
+ // the side-question panel. Refusing it here broke a working feature.
73
+ expect(isTuiOnlySlashCommand('/btw')).toBe(false);
74
+ expect(isTuiOnlySlashCommand('/btw how does this work')).toBe(false);
75
+ });
76
+
77
+ test('ordinary prose is never a command', () => {
78
+ expect(isTuiOnlySlashCommand('explain the plan')).toBe(false);
79
+ expect(isTuiOnlySlashCommand('what does /clear do?')).toBe(false);
80
+ expect(isTuiOnlySlashCommand('')).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('tuiOnlyCommandNotice', () => {
85
+ test('names the command and says where it works', () => {
86
+ const notice = tuiOnlyCommandNotice('/plan do the thing');
87
+ expect(notice).toContain('`/plan`');
88
+ expect(notice).toContain('omp terminal only');
89
+ });
90
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Slash commands oh-my-pi implements only for its own TUI.
8
+ *
9
+ * omp's registry carries a `handle` (text/ACP mode) for 42 of its 82 builtins
10
+ * and `handleTui` alone for the other 40. `executeAcpBuiltinSlashCommand`
11
+ * requires `handle`, and `buildAvailableSlashCommands` skips entries without
12
+ * one — which is why none of these names appear in the composer popup.
13
+ *
14
+ * The popup is not the whole surface: a name can still be typed, pasted from
15
+ * the docs, or arrive through the follow-up queue. RPC forwards such a prompt
16
+ * to `session.prompt()` verbatim, where the only slash handling left is file
17
+ * commands and prompt templates — so `/plan` reaches the MODEL as literal text
18
+ * and it improvises a turn around it. Measured on omp 18.3.5: `/hotkeys`
19
+ * started a run whose transcript opened with the raw string, the model read
20
+ * `omp://slash-command-internals.md` and ran bash and grep to work out what it
21
+ * meant. `/clear` cleared nothing, `/new` opened nothing, `/login` logged in
22
+ * nothing. The TUI answers all of these instantly and locally.
23
+ *
24
+ * The table is data extracted from the installed omp's own registry, so a name
25
+ * omp moves to text mode stops being guarded by deleting its row. It is
26
+ * deliberately keyed on NAMES ONLY: the guard must not depend on the version of
27
+ * the running child, because a chamber talking to an older omp would otherwise
28
+ * refuse a command that build actually implements.
29
+ *
30
+ * `btw` is listed because omp's entry is TUI-only — the chamber's own `/btw`
31
+ * is intercepted before this predicate is consulted (see `dispatchBtwCommand`),
32
+ * so the two never conflict.
33
+ */
34
+
35
+ import { CHAMBER_COMMANDS } from '@/shared/lib/chat/composer/trigger';
36
+
37
+ /**
38
+ * Commands the CHAMBER answers itself, derived from the one list that defines
39
+ * them. They are in omp's TUI-only set (omp implements `/btw` with `handleTui`
40
+ * only) but they are NOT unavailable here — the composer intercepts them before
41
+ * any guard runs.
42
+ *
43
+ * Derived rather than restated: a second hand-kept list is exactly what drifts,
44
+ * and a name that moved into `CHAMBER_COMMANDS` without a matching row here
45
+ * would be refused by the very guard that exists to protect it.
46
+ */
47
+ export const CHAMBER_OWNED_SLASH_COMMANDS: Record<string, true> = Object.fromEntries(
48
+ CHAMBER_COMMANDS.map((command) => [command.name.toLowerCase(), true as const]),
49
+ );
50
+
51
+ /** Every TUI-only builtin name and alias, lowercased. */
52
+ export const TUI_ONLY_SLASH_COMMANDS: Record<string, true> = {
53
+ settings: true,
54
+ setup: true,
55
+ providers: true,
56
+ plan: true,
57
+ 'plan-review': true,
58
+ vibe: true,
59
+ goal: true,
60
+ 'guided-goal': true,
61
+ loop: true,
62
+ queue: true,
63
+ collab: true,
64
+ join: true,
65
+ leave: true,
66
+ copy: true,
67
+ open: true,
68
+ hotkeys: true,
69
+ extensions: true,
70
+ status: true,
71
+ agents: true,
72
+ git: true,
73
+ hub: true,
74
+ branch: true,
75
+ rewind: true,
76
+ fork: true,
77
+ tree: true,
78
+ login: true,
79
+ logout: true,
80
+ new: true,
81
+ clear: true,
82
+ delete: true,
83
+ resume: true,
84
+ btw: true,
85
+ tan: true,
86
+ omfg: true,
87
+ cleanse: true,
88
+ debug: true,
89
+ exit: true,
90
+ restart: true,
91
+ skills: true,
92
+ live: true,
93
+ record: true,
94
+ pause: true,
95
+ quit: true,
96
+ q: true,
97
+ };
98
+
99
+ /** The command token of a draft, lowercased, or null when it is not a slash
100
+ * invocation. Mirrors omp's own parse: the name ends at the first space or
101
+ * colon, and the token must START the prompt (leading whitespace allowed) —
102
+ * a `/` inside prose is a path, not a command. */
103
+ export function slashCommandName(text: string): string | null {
104
+ const trimmed = text.trimStart();
105
+ if (!trimmed.startsWith('/')) return null;
106
+ const rest = trimmed.slice(1);
107
+ const end = rest.search(/[\s:]/);
108
+ const name = end === -1 ? rest : rest.slice(0, end);
109
+ return name ? name.toLowerCase() : null;
110
+ }
111
+
112
+ /**
113
+ * Whether a draft invokes a command the chamber cannot run, because omp only
114
+ * implements it in its TUI. Such a prompt must be refused locally — forwarding
115
+ * it makes the model answer a command it never received.
116
+ *
117
+ * A command the CHAMBER owns is excluded: `/btw` is TUI-only in omp, but the
118
+ * chamber answers it with the side-question panel, so refusing it here would
119
+ * break a working feature. The client intercepts those before consulting this
120
+ * predicate; the exclusion is what keeps the SERVER-side guard (which sees only
121
+ * the text, e.g. on a queued delivery) from refusing them too.
122
+ */
123
+ export function isTuiOnlySlashCommand(text: string): boolean {
124
+ const name = slashCommandName(text);
125
+ if (name === null) return false;
126
+ if (CHAMBER_OWNED_SLASH_COMMANDS[name] === true) return false;
127
+ return TUI_ONLY_SLASH_COMMANDS[name] === true;
128
+ }
129
+
130
+ /**
131
+ * The notice shown in place of a refused turn. Names the command and says where
132
+ * it does work, because "not supported" alone leaves the user guessing whether
133
+ * they mistyped it.
134
+ */
135
+ export function tuiOnlyCommandNotice(text: string): string {
136
+ const name = slashCommandName(text) ?? text.trim();
137
+ return `\`/${name}\` is available in the omp terminal only — it has no effect over the RPC bridge, so it was not sent.`;
138
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The one normalization every notice row's text goes through.
8
+ *
9
+ * Two paths produce the same row and they must agree, or a command's output
10
+ * changes appearance when the page is refreshed: the live `command_output`
11
+ * frame (`chat/omp/agent-events.ts`) and the custom entry a reload replays
12
+ * (`omp/session/messages-map.ts`).
13
+ *
14
+ * What it removes, and why:
15
+ *
16
+ * - **ANSI escapes.** omp formats command output for a terminal — SGR colour
17
+ * runs and half-block progress bars. The notice card renders markdown through
18
+ * Shiki, which treats `\x1b[38;2;…m` as literal text, so `/context` drew 48
19
+ * escape bytes as `38;2;107;114;128m████…`. True colour stays available in the
20
+ * realtime xterm panel, which parses ANSI natively.
21
+ * - **`<system-notice>` wrappers.** omp tags its own notices; the tag is
22
+ * scaffolding, not content. (`<system-reminder>` / `<task-result>` are NOT
23
+ * stripped — those identify the card variant `SystemNotice` renders.)
24
+ */
25
+
26
+ import { stripAnsiCodes } from '@/shared/lib/code/ansi';
27
+
28
+ /** Strip terminal formatting and notice scaffolding, then trim. */
29
+ export function normalizeNoticeText(text: string): string {
30
+ return stripAnsiCodes(text)
31
+ .replace(/<\/?system-notice[^>]*>/g, '')
32
+ .trim();
33
+ }
@@ -13,9 +13,11 @@
13
13
  * size ceiling.
14
14
  */
15
15
 
16
- import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
17
- import type { ChatMessageData, IncomingExtensionUiRequest, OmpAgentCallbacks, OmpAgentEvent, OmpAgentState, ToolCallData } from '@/shared/types';
16
+ import type { IncomingExtensionUiRequest, OmpAgentCallbacks, OmpAgentEvent, ToolCallData } from '@/shared/types';
18
17
  import { extractTextFromContent, toChatMessage, toolResultText } from '@/shared/lib/omp/session/mapper';
18
+ import { normalizeNoticeText } from '@/shared/lib/chat/notice-text';
19
+ import { invalidateComposerCache } from '@/shared/lib/chat/composer/client';
20
+ import { setActivity, toolHost, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/fold-deps';
19
21
  import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
20
22
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
21
23
  import { describeAssistantPhase, describeToolActivity } from '@/shared/lib/chat/timeline/tool-verbs';
@@ -25,43 +27,13 @@ import {
25
27
  putToolResult,
26
28
  recordToolResult,
27
29
  refreshToolMessage,
28
- type ToolResultHost,
29
30
  type ToolResultRecord,
30
31
  } from '@/shared/lib/chat/omp/tool-results';
31
32
 
32
33
  // Re-exported so stream.ts and the timeline hook keep their existing import
33
34
  // site; the implementation lives in tool-results.ts.
34
35
  export type { ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
35
-
36
- export interface OmpAgentFoldDeps extends ToolResultHost {
37
- sessionId: string;
38
- setState: Dispatch<SetStateAction<OmpAgentState>>;
39
- callbacksRef: RefObject<OmpAgentCallbacks>;
40
- toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
41
- lastToolMessageRef: RefObject<ChatMessageData>;
42
- interruptPendingRef: RefObject<boolean>;
43
- /** Last activity phrase published to the indicator; guards per-token frames
44
- * from re-setting identical state. */
45
- activityRef: RefObject<string>;
46
- /** Thinking level in effect for the live run (last `thinking_level_changed`
47
- * frame); stamped onto assistant turns as they stream. */
48
- currentThinkingLevelRef: RefObject<string | undefined>;
49
- /** toolCallIds of in-flight file-mutating calls, cleared on `agent_start`. */
50
- fileMutatingCallsRef: RefObject<Set<string>>;
51
- }
52
-
53
- /** Publish a new indicator phrase, skipping repeats (thinking/text deltas
54
- * arrive per token and would otherwise re-set state on every frame). */
55
- function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
56
- if (!verb || verb === deps.activityRef.current) return;
57
- deps.activityRef.current = verb;
58
- deps.callbacksRef.current?.onActivity?.(verb);
59
- }
60
-
61
- /** Narrow a deps record to the tool-output host, adding the re-emit sink. */
62
- function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
63
- return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
64
- }
36
+ export type { OmpAgentFoldDeps } from '@/shared/lib/chat/omp/fold-deps';
65
37
 
66
38
  /** Flush assistant turns that stopped abnormally and were never streamed as
67
39
  * `message_end`. A user abort is the one terminal path where omp emits no
@@ -274,8 +246,10 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
274
246
 
275
247
  // Built-in slash command output (/usage, /compact result, …). Rendered as
276
248
  // a notice row — see the callback type note: this frame is the ONLY copy.
249
+ // `normalizeNoticeText` strips the terminal formatting omp emits here; the
250
+ // reload path applies the same function so both agree.
277
251
  case 'command_output': {
278
- const text = typeof data.text === 'string' ? data.text.trim() : '';
252
+ const text = typeof data.text === 'string' ? normalizeNoticeText(data.text) : '';
279
253
  if (text) callbacks?.onCommandOutput?.(text);
280
254
  break;
281
255
  }
@@ -329,10 +303,17 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
329
303
  deps.currentThinkingLevelRef.current = normalizeThinkingLevel(data.thinkingLevel);
330
304
  break;
331
305
 
332
- // No chamber-side effect: config/command inventory frames and the
333
- // transport's own `connected` greeting.
334
- case 'config_update':
306
+ // omp emits this whenever the discovered command set changes — after
307
+ // `/reload-plugins`, `/move`, or a skill install. The composer's command
308
+ // pool is cached five minutes (`composer/client.ts`), so without this the
309
+ // popup keeps offering the pre-change list.
335
310
  case 'available_commands_update':
311
+ invalidateComposerCache('command');
312
+ break;
313
+
314
+ // No chamber-side effect: config frames and the transport's own `connected`
315
+ // greeting.
316
+ case 'config_update':
336
317
  case 'connected':
337
318
  default:
338
319
  break;