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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +275 -268
- package/dist/client/static/js/{index-e3axkbk1.js → index-h05z90mw.js} +581 -581
- package/package.json +1 -1
- package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
- package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
- package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
- package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
- package/src/client/components/workspace/btw-panel/index.tsx +20 -27
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
- package/src/client/hooks/chat/btw/index.ts +7 -73
- package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
- package/src/client/hooks/chat/btw/intercept.ts +5 -2
- package/src/client/hooks/chat/btw/mode.ts +16 -76
- package/src/client/hooks/chat/timeline/actions.ts +22 -30
- package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
- package/src/client/hooks/chat/timeline/send.ts +8 -1
- package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
- package/src/server/lib/assets/font-css.server.test.ts +87 -0
- package/src/server/lib/assets/font-css.server.ts +46 -20
- package/src/server/lib/assets/font-faces.ts +69 -0
- package/src/server/lib/assets/fonts.server.ts +27 -4
- package/src/server/lib/btw/child.server.test.ts +68 -0
- package/src/server/lib/btw/child.server.ts +17 -16
- package/src/server/lib/btw/frames.test.ts +3 -3
- package/src/server/lib/btw/frames.ts +1 -1
- package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
- package/src/server/lib/btw/lifecycle.server.ts +41 -31
- package/src/server/lib/btw/prompt.ts +7 -11
- package/src/server/lib/btw/registry.server.ts +1 -9
- package/src/server/lib/btw/reply.ts +54 -0
- package/src/server/lib/btw/runtime.server.test.ts +128 -0
- package/src/server/lib/btw/runtime.server.ts +77 -86
- package/src/server/lib/btw/schema.server.test.ts +84 -0
- package/src/server/lib/btw/schema.server.ts +8 -0
- package/src/server/lib/btw/service.server.ts +47 -78
- package/src/server/lib/btw/store.server.ts +9 -20
- package/src/server/lib/bundler/build-errors.server.ts +8 -3
- package/src/server/lib/bundler/css.ts +12 -61
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -0
- package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
- package/src/server/lib/omp/session/merge-stored.ts +70 -1
- package/src/server/lib/omp/session/messages.ts +27 -2
- package/src/server/plugins/ssr.ts +4 -4
- package/src/server/plugins/static.ts +14 -10
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/shared/lib/chat/btw/client.ts +0 -30
- package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
- package/src/shared/lib/chat/composer/tui-only.ts +138 -0
- package/src/shared/lib/chat/notice-text.ts +33 -0
- package/src/shared/lib/chat/omp/agent-events.ts +18 -37
- package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
- package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
- package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
- package/src/shared/lib/chat/timeline/command-output.ts +65 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
- package/src/shared/lib/omp/session/messages-map.ts +6 -1
- 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
|
-
|
|
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`
|
|
139
|
-
//
|
|
140
|
-
// `<link href="/fonts.css">` in
|
|
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,
|
|
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
|
|
25
|
-
// server from anywhere, and a cwd-relative `public` would not
|
|
26
|
-
// icon, the manifest and the service worker would 404.
|
|
27
|
-
|
|
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(
|
|
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
|
|
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
|
|
148
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
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;
|