ompchamber 3.3.3 → 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 (55) 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-8ejde0b7.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/btw/child.server.test.ts +68 -0
  23. package/src/server/lib/btw/child.server.ts +17 -16
  24. package/src/server/lib/btw/frames.test.ts +3 -3
  25. package/src/server/lib/btw/frames.ts +1 -1
  26. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  27. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  28. package/src/server/lib/btw/prompt.ts +7 -11
  29. package/src/server/lib/btw/registry.server.ts +1 -9
  30. package/src/server/lib/btw/reply.ts +54 -0
  31. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  32. package/src/server/lib/btw/runtime.server.ts +77 -86
  33. package/src/server/lib/btw/schema.server.test.ts +84 -0
  34. package/src/server/lib/btw/schema.server.ts +8 -0
  35. package/src/server/lib/btw/service.server.ts +47 -78
  36. package/src/server/lib/btw/store.server.ts +9 -20
  37. package/src/server/lib/omp/rpc/constants.ts +10 -0
  38. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  39. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  41. package/src/server/lib/omp/session/messages.ts +27 -2
  42. package/src/server/routes/agent/command.ts +9 -1
  43. package/src/server/routes/btw/topics.ts +2 -59
  44. package/src/shared/lib/chat/btw/client.ts +0 -30
  45. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  46. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  47. package/src/shared/lib/chat/notice-text.ts +33 -0
  48. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  49. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  50. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  51. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  52. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  53. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  54. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  55. package/src/shared/types/btw.ts +13 -34
@@ -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;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * `available_commands_update` must drop the composer's cached command pool.
6
+ * The cache lives five minutes, and this frame is the only signal that omp's
7
+ * discovered command set changed (`/reload-plugins`, `/move`, a skill install),
8
+ * so ignoring it leaves the popup offering a list omp no longer matches.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import { foldAgentEvent, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/agent-events';
14
+ import { invalidateComposerCache, loadComposerItems } from '@/shared/lib/chat/composer/client';
15
+
16
+ function foldDeps(): OmpAgentFoldDeps {
17
+ const noop = () => {};
18
+ const ref = <T,>(value: T) => ({ current: value });
19
+ return {
20
+ setState: noop,
21
+ sessionId: 'test-session',
22
+ callbacksRef: { current: {} },
23
+ toolResultsRef: ref(new Map()),
24
+ lastToolMessageRef: ref(null),
25
+ interruptPendingRef: ref(false),
26
+ activityRef: ref(''),
27
+ currentThinkingLevelRef: ref(undefined),
28
+ fileMutatingCallsRef: ref(new Set()),
29
+ } as unknown as OmpAgentFoldDeps;
30
+ }
31
+
32
+ describe('available_commands_update', () => {
33
+ test('refetches the command pool on the next composer open', async () => {
34
+ let fetches = 0;
35
+ const originalFetch = globalThis.fetch;
36
+ globalThis.fetch = (async (url: string | URL | Request) => {
37
+ const href = String(url);
38
+ if (href.includes('/api/settings/commands')) {
39
+ fetches += 1;
40
+ return new Response(JSON.stringify({ commands: [] }), { status: 200 });
41
+ }
42
+ if (href.includes('/api/settings/skills')) {
43
+ return new Response(JSON.stringify({ skills: [] }), { status: 200 });
44
+ }
45
+ return new Response('{}', { status: 200 });
46
+ }) as typeof fetch;
47
+
48
+ try {
49
+ invalidateComposerCache();
50
+ await loadComposerItems('command');
51
+ expect(fetches).toBe(1);
52
+ // Cached: a second open inside the TTL does not refetch.
53
+ await loadComposerItems('command');
54
+ expect(fetches).toBe(1);
55
+
56
+ // omp reports the command set changed.
57
+ foldAgentEvent({ type: 'available_commands_update', commands: [] }, foldDeps());
58
+
59
+ await loadComposerItems('command');
60
+ expect(fetches).toBe(2);
61
+ } finally {
62
+ globalThis.fetch = originalFetch;
63
+ invalidateComposerCache();
64
+ }
65
+ });
66
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * Builtin-command output must be stripped of ANSI on BOTH paths that can render
6
+ * it: the live `command_output` frame and the custom entry a reload replays.
7
+ * The two are separate code paths, and a fix applied to only one makes a
8
+ * command's output change appearance when the page is refreshed — measured on
9
+ * `/context`, whose progress bars carry 48 escape bytes that the notice card
10
+ * drew literally as `38;2;107;114;128m████…`.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import { foldAgentEvent, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/agent-events';
16
+ import { noticeFromCustomMessage } from '@/shared/lib/omp/session/messages-map';
17
+
18
+ /** The real shape omp emits for `/context`: SGR runs around half-block bars. */
19
+ const CONTEXT_OUTPUT = 'Context window: 1000000 tokens (1% used)\n'
20
+ + ' System prompt [\u001b[38;2;107;114;128m░░░░░░\u001b[39m\u001b[1m\u001b[38;2;0;180;255m░░░░\u001b[22m\u001b[39m] 0% 2541 tokens';
21
+
22
+ function foldDeps(): { deps: OmpAgentFoldDeps; outputs: string[] } {
23
+ const outputs: string[] = [];
24
+ const noop = () => {};
25
+ const ref = <T,>(value: T) => ({ current: value });
26
+ const deps = {
27
+ setState: noop,
28
+ sessionId: 'test-session',
29
+ callbacksRef: {
30
+ current: {
31
+ onCommandOutput: (text: string) => outputs.push(text),
32
+ },
33
+ },
34
+ toolResultsRef: ref(new Map()),
35
+ lastToolMessageRef: ref(null),
36
+ interruptPendingRef: ref(false),
37
+ activityRef: ref(''),
38
+ currentThinkingLevelRef: ref(undefined),
39
+ fileMutatingCallsRef: ref(new Set()),
40
+ } as unknown as OmpAgentFoldDeps;
41
+ return { deps, outputs };
42
+ }
43
+
44
+ describe('command_output ANSI stripping (live path)', () => {
45
+ test('strips escapes before the notice row sees them', () => {
46
+ const { deps, outputs } = foldDeps();
47
+ foldAgentEvent({ type: 'command_output', text: CONTEXT_OUTPUT }, deps);
48
+
49
+ expect(outputs).toHaveLength(1);
50
+ expect(outputs[0]).not.toContain('\u001b');
51
+ // The content itself survives — only the escapes go.
52
+ expect(outputs[0]).toContain('Context window: 1000000 tokens');
53
+ expect(outputs[0]).toContain('░░░░░░');
54
+ });
55
+
56
+ test('keeps the empty-frame guard', () => {
57
+ const { deps, outputs } = foldDeps();
58
+ foldAgentEvent({ type: 'command_output', text: ' \u001b[39m ' }, deps);
59
+ expect(outputs).toEqual([]);
60
+ });
61
+ });
62
+
63
+ describe('noticeFromCustomMessage ANSI stripping (reload path)', () => {
64
+ test('strips escapes from a replayed custom entry', () => {
65
+ const notice = noticeFromCustomMessage({
66
+ type: 'custom_message',
67
+ customType: 'command_output',
68
+ content: CONTEXT_OUTPUT,
69
+ });
70
+
71
+ expect(notice).not.toBeNull();
72
+ expect(notice?.notice).not.toContain('\u001b');
73
+ expect(notice?.notice).toContain('Context window: 1000000 tokens');
74
+ });
75
+
76
+ test('still unwraps a system-notice envelope', () => {
77
+ const notice = noticeFromCustomMessage({
78
+ type: 'custom_message',
79
+ customType: 'notice',
80
+ content: '<system-notice>\u001b[32mAll good\u001b[39m</system-notice>',
81
+ });
82
+ expect(notice?.notice).toBe('All good');
83
+ });
84
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The deps record `foldAgentEvent` reads, plus the two small helpers that
8
+ * narrow it. Split out of `agent-events.ts` so that file — which is already the
9
+ * switch over every frame omp can send — stays under the repo's per-file size
10
+ * ceiling.
11
+ *
12
+ * These are pure accessors over caller-owned state: no closure of their own, no
13
+ * module state, so importing them from either side of the fold is free.
14
+ */
15
+
16
+ import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
17
+ import type { ChatMessageData, OmpAgentCallbacks, OmpAgentState } from '@/shared/types';
18
+ import type { ToolResultHost, ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
19
+
20
+ export interface OmpAgentFoldDeps extends ToolResultHost {
21
+ sessionId: string;
22
+ setState: Dispatch<SetStateAction<OmpAgentState>>;
23
+ callbacksRef: RefObject<OmpAgentCallbacks>;
24
+ toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
25
+ lastToolMessageRef: RefObject<ChatMessageData>;
26
+ interruptPendingRef: RefObject<boolean>;
27
+ /** Last activity phrase published to the indicator; guards per-token frames
28
+ * from re-setting identical state. */
29
+ activityRef: RefObject<string>;
30
+ /** Thinking level in effect for the live run (last `thinking_level_changed`
31
+ * frame); stamped onto assistant turns as they stream. */
32
+ currentThinkingLevelRef: RefObject<string | undefined>;
33
+ /** toolCallIds of in-flight file-mutating calls, cleared on `agent_start`. */
34
+ fileMutatingCallsRef: RefObject<Set<string>>;
35
+ }
36
+
37
+ /** Publish a new indicator phrase, skipping repeats (thinking/text deltas
38
+ * arrive per token and would otherwise re-set state on every frame). */
39
+ export function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
40
+ if (!verb || verb === deps.activityRef.current) return;
41
+ deps.activityRef.current = verb;
42
+ deps.callbacksRef.current?.onActivity?.(verb);
43
+ }
44
+
45
+ /** Narrow a deps record to the tool-output host, adding the re-emit sink. */
46
+ export function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
47
+ return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
48
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `command_output` fold step: a builtin command's result becomes a notice
8
+ * row, and that row is PERSISTED.
9
+ *
10
+ * Persistence is the point. omp answers a builtin on the command path and
11
+ * writes nothing to the session JSONL — the output exists only in this frame —
12
+ * so a timeline rebuilt from the file had forgotten a `/context` the user just
13
+ * ran. The chamber's own copy keeps it, and `mergeOmpAttachments` splices it
14
+ * back under the turn that produced it on the next load.
15
+ *
16
+ * Split out of `omp-callbacks.ts` (which keeps only the wiring) so both files
17
+ * stay under the repo's per-file size ceiling.
18
+ */
19
+
20
+ import type { Dispatch, SetStateAction } from 'preact/compat';
21
+ import type { ChatMessageData } from '@/shared/types';
22
+
23
+ export interface CommandOutputDeps {
24
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
25
+ /** The optimistic AI bubble this run is streaming into, if any. */
26
+ aiPlaceholderIdRef: { current: string | null };
27
+ persistMessages: (messages: ChatMessageData[]) => void;
28
+ }
29
+
30
+ /**
31
+ * Append a command's output as a notice row, before the active placeholder.
32
+ *
33
+ * Placing it before the placeholder is what keeps it under the turn that
34
+ * produced it: the placeholder becomes the assistant's answer, and the notice
35
+ * must read as the command's result rather than as part of that answer. With no
36
+ * placeholder the row goes at the tail.
37
+ */
38
+ export function appendCommandOutputNotice(text: string, deps: CommandOutputDeps): void {
39
+ const { setLocalMessages, aiPlaceholderIdRef, persistMessages } = deps;
40
+ const row: ChatMessageData = {
41
+ id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
42
+ role: 'ai',
43
+ content: '',
44
+ notice: text,
45
+ };
46
+ setLocalMessages(prev => {
47
+ const placeholderId = aiPlaceholderIdRef.current;
48
+ const pIdx = placeholderId ? prev.findIndex(m => m.id === placeholderId) : -1;
49
+ if (pIdx !== -1) {
50
+ const next = [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
51
+ // The streaming placeholder is deliberately NOT persisted — it is a
52
+ // client-side bubble that `onPromptSettled` removes, and executeSend
53
+ // filters it for the same reason. Writing it here left an empty assistant
54
+ // row in the chamber's copy of a command-only turn.
55
+ persistMessages(next.filter(m => m.id !== placeholderId));
56
+ return next;
57
+ }
58
+ // Already recorded (the same command re-run while nothing streams): keep
59
+ // the list as-is rather than stacking an identical card.
60
+ if (prev.some(m => m.notice === text)) return prev;
61
+ const next = [...prev, row];
62
+ persistMessages(next);
63
+ return next;
64
+ });
65
+ }
@@ -23,6 +23,7 @@ import {
23
23
  queueStreamingUpdate,
24
24
  } from '@/shared/lib/chat/timeline/stream-coalescer';
25
25
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
26
+ import { appendCommandOutputNotice } from '@/shared/lib/chat/timeline/command-output';
26
27
 
27
28
  export interface OmpAgentCallbacksDeps {
28
29
  setGenerating: (v: boolean) => void;
@@ -303,24 +304,10 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
303
304
  });
304
305
  }
305
306
  },
306
- // Built-in slash command output only rides the live stream — append it as
307
- // a notice row before any active placeholder, exactly like omp notices.
307
+ // Built-in slash command output: a notice row, persisted — see the module
308
+ // doc for why the chamber's own copy is the only place it can survive.
308
309
  onCommandOutput: (text) => {
309
- const row: ChatMessageData = {
310
- id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
311
- role: 'ai',
312
- content: '',
313
- notice: text,
314
- };
315
- setLocalMessages(prev => {
316
- const placeholderId = aiPlaceholderIdRef.current;
317
- if (placeholderId) {
318
- const pIdx = prev.findIndex(m => m.id === placeholderId);
319
- if (pIdx !== -1) return [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
320
- }
321
- if (prev.some(m => m.notice === text)) return prev;
322
- return [...prev, row];
323
- });
310
+ appendCommandOutputNotice(text, { setLocalMessages, aiPlaceholderIdRef, persistMessages });
324
311
  },
325
312
  // omp renamed the session — the auto-title generation the chamber asks for
326
313
  // after a settled run, a `/rename`, or an RPC set_session_name. The slot
@@ -6,6 +6,7 @@
6
6
  import type { ChatMessageData } from '@/shared/types/chat';
7
7
  import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
8
8
  import { isRecord } from '@/shared/lib/util/guards';
9
+ import { normalizeNoticeText } from '@/shared/lib/chat/notice-text';
9
10
  import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
10
11
  import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
11
12
  import { toEpochMs } from '@/shared/lib/omp/session/timestamps';
@@ -180,7 +181,11 @@ export function noticeFromCustomMessage(record: Record<string, unknown>): ChatMe
180
181
  .map((b) => (isRecord(b) && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
181
182
  .join('')
182
183
  : '';
183
- const notice = text.replace(/<\/?system-notice[^>]*>/g, '').trim();
184
+ // Same terminal-formatted text the live `command_output` frame carries: a
185
+ // custom entry replayed from the JSONL keeps its ANSI runs, and the notice
186
+ // card renders them literally. One normalizer serves both paths so a command's
187
+ // output looks identical whether it was just run or reloaded.
188
+ const notice = normalizeNoticeText(text);
184
189
  if (!notice) return null;
185
190
  return {
186
191
  id,