ompchamber 0.8.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Mounts an xterm instance on a container and keeps it bound to a PTY.
8
+ *
9
+ * Extracted from the view component so the mount lifecycle (xterm import, fit
10
+ * addon, input wiring, observers) stays readable and the component remains a
11
+ * render layer. Everything the caller must know travels through `callbacks`;
12
+ * the returned handle is the only way back in.
13
+ *
14
+ * Two behaviours here are deliberate:
15
+ *
16
+ * - **Attach waits for a real grid.** The terminal panel stays mounted while
17
+ * its view is CSS-hidden (that is what keeps a shell alive across panel
18
+ * switches), and a hidden container measures 0px — fitting is skipped and
19
+ * xterm keeps its 80x24 default. Attaching then would create the shell at a
20
+ * size the panel never had, and the retained scrollback would replay at that
21
+ * width forever. So attach is driven by the same ResizeObserver that detects
22
+ * the panel becoming visible.
23
+ * - **Raw bytes in, raw bytes out.** `onData`/`onBinary` forward keystrokes
24
+ * untouched, and `write` takes a `Uint8Array` so xterm's streaming UTF-8
25
+ * decoder reassembles a character split across two PTY reads.
26
+ * - **xterm and the WebGL addon are dynamically imported on purpose.** xterm is
27
+ * ~300 KB and the addon pulls a WebGL context into the panel; both stay out
28
+ * of the initial client bundle until the terminal panel is actually opened.
29
+ * The module shape varies by build, hence the probe pattern below.
30
+ */
31
+
32
+ import type { Terminal } from '@xterm/xterm';
33
+ import type { FitAddon } from '@xterm/addon-fit';
34
+ import type { WebglAddon } from '@xterm/addon-webgl';
35
+ import {
36
+ getXtermTheme,
37
+ XTERM_FONT_FAMILY,
38
+ safePatchFitAddon,
39
+ safePatchRenderService,
40
+ type XtermCore,
41
+ type XtermModule,
42
+ } from '@/client/data/theme/terminal';
43
+
44
+ export interface XtermMountCallbacks {
45
+ /** Keystrokes, paste, mouse reports — straight to the PTY. */
46
+ onInput: (data: string) => void;
47
+ /** Non-UTF-8 payloads xterm reports separately (some mouse reports). */
48
+ onBinaryInput: (data: string) => void;
49
+ /** The grid changed; the PTY must be resized to match. */
50
+ onGridChange: (cols: number, rows: number) => void;
51
+ /** The terminal is measurable and knows its size; attach with these. */
52
+ onReady: (cols: number, rows: number) => void;
53
+ /** Viewport scrolled away from the live bottom. */
54
+ onScrolledUpChange: (scrolledUp: boolean) => void;
55
+ }
56
+
57
+ export interface XtermMount {
58
+ term: Terminal;
59
+ fitAddon: FitAddon;
60
+ /**
61
+ * Lay the retained scrollback out at the size it was drawn for, then fit the
62
+ * panel. Grid reports are suppressed until the replay has been consumed.
63
+ */
64
+ writeReplay: (bytes: Uint8Array, cols: number, rows: number) => void;
65
+ dispose: () => void;
66
+ }
67
+
68
+ /**
69
+ * Mount xterm inside `container`. Resolves to null when the module could not be
70
+ * loaded or the component unmounted while importing.
71
+ */
72
+ export async function mountXterm(
73
+ container: HTMLElement,
74
+ isDark: boolean,
75
+ callbacks: XtermMountCallbacks,
76
+ isCancelled: () => boolean,
77
+ ): Promise<XtermMount | null> {
78
+ // Dynamic on purpose: xterm is ~300 KB and is only needed once the terminal
79
+ // panel is actually opened, so it stays out of the initial client bundle
80
+ // (the module shape varies by build, hence the probes).
81
+ const xtermModule = (await import('@xterm/xterm')) as unknown as XtermModule<typeof Terminal>;
82
+ const fitModule = (await import('@xterm/addon-fit')) as unknown as XtermModule<typeof FitAddon>;
83
+
84
+ const TerminalClass = xtermModule.Terminal || xtermModule.default?.Terminal || xtermModule.default;
85
+ const FitAddonClass = fitModule.FitAddon || fitModule.default?.FitAddon || fitModule.default;
86
+ if (!TerminalClass || !FitAddonClass || isCancelled()) return null;
87
+
88
+ safePatchFitAddon(FitAddonClass);
89
+
90
+ const term = new TerminalClass({
91
+ cursorBlink: true,
92
+ cursorStyle: 'block',
93
+ fontSize: 12,
94
+ lineHeight: 1.25,
95
+ fontFamily: XTERM_FONT_FAMILY,
96
+ theme: getXtermTheme(isDark),
97
+ // No `convertEol`: the PTY already emits CRLF, and converting a lone LF
98
+ // would re-introduce the line-feed doubling a real terminal avoids.
99
+ scrollback: 10000,
100
+ scrollSensitivity: 1.5,
101
+ fastScrollSensitivity: 5,
102
+ smoothScrollDuration: 0,
103
+ allowProposedApi: true,
104
+ }) as Terminal;
105
+
106
+ term.open(container);
107
+ safePatchRenderService(term);
108
+
109
+ // WebGL renderer: draws powerline glyphs (U+E0B0 etc.) as full-cell vector
110
+ // paths, so segmented prompts stay seamless the way a local terminal does.
111
+ // The DOM renderer draws them as ordinary glyphs inside the font box, which
112
+ // leaves notches at every segment joint. WebGL can fail (no GPU context,
113
+ // context-lost), and the DOM renderer is the automatic fallback.
114
+ try {
115
+ const webglModule = (await import('@xterm/addon-webgl')) as unknown as {
116
+ WebglAddon?: typeof WebglAddon;
117
+ default?: { WebglAddon?: typeof WebglAddon } & typeof WebglAddon;
118
+ };
119
+ const WebglAddonClass = webglModule.WebglAddon || webglModule.default?.WebglAddon || webglModule.default;
120
+ if (WebglAddonClass) {
121
+ const webgl = new WebglAddonClass() as WebglAddon;
122
+ webgl.onContextLoss(() => webgl.dispose());
123
+ term.loadAddon(webgl);
124
+ }
125
+ } catch {}
126
+
127
+ const fitAddon = new FitAddonClass() as FitAddon;
128
+ term.loadAddon(fitAddon);
129
+
130
+ term.attachCustomKeyEventHandler((event: globalThis.KeyboardEvent) => handleKeyNavigation(term, event));
131
+
132
+ const disposables = [
133
+ term.onScroll(() => {
134
+ const buffer = term.buffer.active;
135
+ callbacks.onScrolledUpChange(buffer.viewportY < buffer.baseY);
136
+ }),
137
+ term.onData((data: string) => callbacks.onInput(data)),
138
+ term.onBinary((data: string) => callbacks.onBinaryInput(data)),
139
+ // FitAddon changes the grid on fit; the shell has to hear about it or a TUI
140
+ // keeps drawing at the old width. Reports are dropped while the replay is
141
+ // being laid out: that grid belongs to the recorded history, not the panel.
142
+ term.onResize(({ cols, rows }) => {
143
+ if (replaying) return;
144
+ callbacks.onGridChange(cols, rows);
145
+ }),
146
+ ];
147
+
148
+ let attached = false;
149
+ let fitTimer: ReturnType<typeof setTimeout> | null = null;
150
+ let frame: number | null = null;
151
+ /**
152
+ * Set while the retained scrollback is being laid out at its recorded size.
153
+ * That transient grid is not the panel's, so it must not be reported as a
154
+ * resize — the shell would answer with a prompt repaint for a size the user
155
+ * never chose, which is how a reattach used to print a second prompt.
156
+ */
157
+ let replaying = false;
158
+
159
+ const safeFit = () => {
160
+ if (isCancelled()) return;
161
+ if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
162
+ const core = (term as unknown as XtermCore)._core;
163
+ const dims = core?._renderService?.dimensions;
164
+ if (!dims?.css?.cell?.width || !dims?.css?.cell?.height) return;
165
+ try {
166
+ fitAddon.fit();
167
+ } catch {}
168
+ };
169
+
170
+ /** Fit now, once xterm has consumed everything already queued. */
171
+ const fitAfterWrites = () => {
172
+ term.write('', () => {
173
+ if (isCancelled()) return;
174
+ replaying = false;
175
+ safeFit();
176
+ });
177
+ };
178
+
179
+ /**
180
+ * Lay the retained scrollback out at the size it was drawn for, then converge
181
+ * on the panel's grid. Re-wrapping history at another width leaves wrapped
182
+ * fragments the shell's own redraw never clears.
183
+ */
184
+ const writeReplay = (bytes: Uint8Array, cols: number, rows: number) => {
185
+ replaying = true;
186
+ if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows);
187
+ if (bytes.length === 0) {
188
+ fitAfterWrites();
189
+ return;
190
+ }
191
+ term.write(bytes, fitAfterWrites);
192
+ };
193
+
194
+ const attachIfSized = () => {
195
+ if (attached || isCancelled()) return;
196
+ if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
197
+ const core = (term as unknown as XtermCore)._core;
198
+ if (!core?._renderService?.dimensions?.css?.cell?.width) return;
199
+ attached = true;
200
+ callbacks.onReady(term.cols, term.rows);
201
+ term.focus();
202
+ };
203
+
204
+ const resizeObserver = new ResizeObserver(() => {
205
+ if (fitTimer) clearTimeout(fitTimer);
206
+ fitTimer = setTimeout(() => {
207
+ safeFit();
208
+ // A panel that was hidden at mount becomes measurable here, which is when
209
+ // the shell should be created — at its real size.
210
+ attachIfSized();
211
+ }, 35);
212
+ });
213
+ resizeObserver.observe(container);
214
+
215
+ const touch = attachTouchScroll(container, term);
216
+
217
+ // The container may already be measurable (the view was active at mount), in
218
+ // which case the observer never fires a first time.
219
+ frame = requestAnimationFrame(() => {
220
+ safeFit();
221
+ attachIfSized();
222
+ });
223
+
224
+ return {
225
+ term,
226
+ fitAddon,
227
+ writeReplay,
228
+ dispose() {
229
+ if (frame !== null) cancelAnimationFrame(frame);
230
+ clearTimeout(fitTimer ?? undefined);
231
+ touch();
232
+ for (const disposable of disposables) disposable.dispose();
233
+ resizeObserver.disconnect();
234
+ try {
235
+ fitAddon.dispose();
236
+ } catch {}
237
+ try {
238
+ term.dispose();
239
+ } catch {}
240
+ },
241
+ };
242
+ }
243
+
244
+ /** Shift+PageUp/Home/End scroll the viewport instead of reaching the shell. */
245
+ function handleKeyNavigation(term: Terminal, event: globalThis.KeyboardEvent): boolean {
246
+ if (event.type !== 'keydown' || !event.shiftKey) return true;
247
+ if (event.key === 'PageUp') { term.scrollPages(-1); return false; }
248
+ if (event.key === 'PageDown') { term.scrollPages(1); return false; }
249
+ if (event.key === 'Home') { term.scrollToTop(); return false; }
250
+ if (event.key === 'End') { term.scrollToBottom(); return false; }
251
+ if (event.key === 'ArrowUp') { term.scrollLines(-1); return false; }
252
+ if (event.key === 'ArrowDown') { term.scrollLines(1); return false; }
253
+ return true;
254
+ }
255
+
256
+ /** Touch drags scroll the scrollback; xterm has no touch handling of its own. */
257
+ function attachTouchScroll(container: HTMLElement, term: Terminal): () => void {
258
+ let startY = 0;
259
+ const onStart = (event: globalThis.TouchEvent) => {
260
+ if (event.touches.length === 1) startY = event.touches[0].clientY;
261
+ };
262
+ const onMove = (event: globalThis.TouchEvent) => {
263
+ if (event.touches.length !== 1) return;
264
+ const deltaY = startY - event.touches[0].clientY;
265
+ if (Math.abs(deltaY) < 16) return;
266
+ term.scrollLines(Math.trunc(deltaY / 16));
267
+ startY = event.touches[0].clientY;
268
+ };
269
+ container.addEventListener('touchstart', onStart, { passive: true });
270
+ container.addEventListener('touchmove', onMove, { passive: true });
271
+ return () => {
272
+ container.removeEventListener('touchstart', onStart);
273
+ container.removeEventListener('touchmove', onMove);
274
+ };
275
+ }
@@ -54,7 +54,33 @@ export function getXtermTheme(isDark: boolean) {
54
54
 
55
55
  export const XTERM_THEME = XTERM_DARK_THEME;
56
56
 
57
- export const XTERM_FONT_FAMILY = "'Fira Code', Menlo, Monaco, 'Courier New', monospace";
57
+ /**
58
+ * Font stack for the terminal canvas.
59
+ *
60
+ * The glyph fallbacks after the text faces are load-bearing. A shell prompt is
61
+ * usually drawn with private-use symbols (powerline separators, devicons,
62
+ * Material Design icons) that **no** coding webfont contains — `@fontsource/
63
+ * fira-code`'s latin subset maps 226 codepoints and not one of them is above
64
+ * U+FFFF — so without a face that supplies them the browser paints a hollow
65
+ * tofu box for every prompt segment. The names below cover the Nerd Font
66
+ * families a user is most likely to have installed (plus the *fontconfig*
67
+ * symbol faces on Linux); a face that is absent is simply skipped by the
68
+ * normal fallback chain, so listing many costs nothing.
69
+ *
70
+ * This is the same shape OpenChamber ships (`TERMINAL_GLYPH_FALLBACKS` in its
71
+ * `ghostty/surface.ts`), which additionally bundles `SymbolsNerdFontMono` as a
72
+ * webfont so the coverage never depends on the machine.
73
+ *
74
+ * Only glyphs the text face lacks reach these entries, so Latin metrics are
75
+ * untouched — verified: `A`/`m`/`W` keep identical advance and ink.
76
+ */
77
+ export const XTERM_GLYPH_FALLBACKS =
78
+ "'FiraCode Nerd Font Mono', 'FiraCode Nerd Font', 'JetBrainsMono Nerd Font', " +
79
+ "'JetBrainsMono NF', 'JetBrains Mono Nerd Font', 'Hack Nerd Font', 'MesloLGS NF', " +
80
+ "'MesloLGM Nerd Font', 'CaskaydiaCove Nerd Font', 'SauceCodePro Nerd Font', " +
81
+ "'Symbols Nerd Font Mono', 'Symbols Nerd Font', 'PowerlineSymbols'";
82
+
83
+ export const XTERM_FONT_FAMILY = `'Fira Code', ${XTERM_GLYPH_FALLBACKS}, Menlo, Monaco, 'Courier New', monospace`;
58
84
 
59
85
  /** xterm's private render service, read only to size-check before fitting. */
60
86
  export interface XtermCore {
@@ -120,31 +146,3 @@ export function safePatchRenderService(term: any) {
120
146
  }
121
147
  } catch {}
122
148
  }
123
-
124
- let terminalSessionOutput = '';
125
-
126
- export function getTerminalSessionOutput(): string {
127
- return terminalSessionOutput;
128
- }
129
-
130
- export function appendTerminalSessionOutput(chunk: string): void {
131
- terminalSessionOutput += chunk;
132
- if (terminalSessionOutput.length > 250000) {
133
- terminalSessionOutput = terminalSessionOutput.slice(-200000);
134
- }
135
- }
136
-
137
- export function clearTerminalSessionOutput(): void {
138
- terminalSessionOutput = '';
139
- }
140
-
141
- const TERMINAL_OUTPUT_SNAPSHOT_LIMIT = 8000;
142
-
143
- export function getTerminalOutputSnapshot(): string {
144
- if (terminalSessionOutput.length <= TERMINAL_OUTPUT_SNAPSHOT_LIMIT) return terminalSessionOutput;
145
- return terminalSessionOutput.slice(-TERMINAL_OUTPUT_SNAPSHOT_LIMIT);
146
- }
147
-
148
- export function setTerminalOutputSnapshot(value: string): void {
149
- terminalSessionOutput = value;
150
- }
@@ -42,7 +42,18 @@ function isComposingInput(native: Event): boolean {
42
42
  );
43
43
  }
44
44
 
45
- /** Orchestrates the composer `@`-agent / `/`-command autocomplete flow. */
45
+ /**
46
+ * Whether the popup may open for this trigger. oh-my-pi only shows the command
47
+ * popup once the slash token has a real match — a bare `/`, a `/tmp/fo` path,
48
+ * or a completed `/retry ` must not cover the composer with an empty list.
49
+ * Mentions keep their own rule: the empty state tells the user what `@` accepts.
50
+ */
51
+ function shouldOpen(trigger: ComposerTrigger | null, matches: ComposerMatchItem[]): boolean {
52
+ if (!trigger) return false;
53
+ return trigger.kind === 'mention' || matches.length > 0;
54
+ }
55
+
56
+ /** Orchestrates the composer `@`-mention / `/`-command autocomplete flow. */
46
57
  export function useComposerTrigger(options: UseComposerTriggerOptions): ComposerTriggerController {
47
58
  const { value, setValue, textareaRef, disabled = false, rootPath } = options;
48
59
 
@@ -54,8 +65,8 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
54
65
  const { items, loading, error } = useComposerItems(trigger?.kind ?? null, rootPath ?? null);
55
66
 
56
67
  const matches = useMemo(
57
- () => filterComposerItems(items, trigger?.query ?? ''),
58
- [items, trigger?.query],
68
+ () => (trigger ? filterComposerItems(items, trigger) : []),
69
+ [items, trigger],
59
70
  );
60
71
 
61
72
  const clampedIndex = matches.length > 0
@@ -66,6 +77,8 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
66
77
 
67
78
  const optionId = useCallback((index: number) => `${listboxId}-option-${index}`, [listboxId]);
68
79
 
80
+ const open = shouldOpen(trigger, matches);
81
+
69
82
  const close = useCallback(() => {
70
83
  setTrigger(null);
71
84
  setActiveIndexState(0);
@@ -112,15 +125,22 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
112
125
  const next = insertToken(value, trigger, tokenForItem(item));
113
126
  pendingCaretRef.current = next.caret;
114
127
  setValue(next.value);
115
- close();
128
+ // Re-query instead of closing: oh-my-pi reopens on the accepted text, so
129
+ // `/fast` → `/fast ` immediately offers its subcommands and the collapsed
130
+ // `/skill:` row expands into the individual skills. `shouldOpen` keeps a
131
+ // completed command from leaving an empty popup behind.
132
+ setTrigger(detectComposerTrigger(next.value, next.caret));
133
+ setActiveIndexState(0);
116
134
  },
117
- [value, trigger, setValue, close],
135
+ [value, trigger, setValue],
118
136
  );
119
137
 
120
138
  const handleKeyDown = useCallback(
121
139
  (e: TargetedKeyboardEvent<HTMLTextAreaElement>): boolean => {
122
140
  if (composingRef.current || e.keyCode === 229) return false;
123
- if (!trigger) return false;
141
+ // A closed popup owns nothing: `/tmp/fo` must keep Enter/Tab meaning send
142
+ // and indent rather than being swallowed by an invisible list.
143
+ if (!trigger || !open) return false;
124
144
 
125
145
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
126
146
  e.preventDefault();
@@ -147,7 +167,7 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
147
167
 
148
168
  return false;
149
169
  },
150
- [trigger, matches, clampedIndex, selectItem, close],
170
+ [trigger, open, matches, clampedIndex, selectItem, close],
151
171
  );
152
172
 
153
173
  useLayoutEffect(() => {
@@ -163,7 +183,7 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
163
183
 
164
184
  return {
165
185
  trigger,
166
- isOpen: trigger !== null,
186
+ isOpen: open,
167
187
  matches,
168
188
  loading,
169
189
  error,
@@ -116,11 +116,28 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
116
116
  const value = useMemo<SidebarDataHandle>(() => {
117
117
  const data = fetcher.data;
118
118
  const seen = seenRef.current;
119
+ // A session that streams again earns a fresh badge lifecycle: forget the
120
+ // ack from an earlier run. Without this, a session opened with a terminal
121
+ // badge stays stripped for the whole mount — its spinner never renders no
122
+ // matter how many times the list refetches, and only a page reload (fresh
123
+ // mount) brings it back.
124
+ if (seen.size && data?.folders) {
125
+ for (const folder of data.folders) {
126
+ for (const session of folder.sessions ?? []) {
127
+ if (session.streamStatus === 'stream') seen.delete(String(session.id));
128
+ }
129
+ }
130
+ }
119
131
  return {
120
132
  folders: ((data?.folders ?? initialFolders) as WorkspaceFolderData[])
121
- // Optimistic badge strip for sessions marked seen this mount.
133
+ // Optimistic badge strip for sessions marked seen this mount. NEVER
134
+ // strip a live `stream` row — the spinner must always render.
122
135
  .map((f) => seen.size
123
- ? { ...f, sessions: (f.sessions ?? []).map((s) => (seen.has(String(s.id)) ? { ...s, streamStatus: undefined } : s)) }
136
+ ? { ...f, sessions: (f.sessions ?? []).map((s) => (
137
+ seen.has(String(s.id)) && s.streamStatus && s.streamStatus !== 'stream'
138
+ ? { ...s, streamStatus: undefined }
139
+ : s
140
+ )) }
124
141
  : f),
125
142
  isMock: data?.isMock ?? false,
126
143
  initializing: !hasLoaded,
@@ -62,7 +62,7 @@ export interface SessionSidebarController {
62
62
  sortOption: SessionSortOption;
63
63
  handleSortChange: (opt: SessionSortOption) => void;
64
64
  processedFolders: WorkspaceFolderData[];
65
- sessionStatus: Record<string, 'stream' | 'finish' | 'abort' | 'error'>;
65
+ sessionStatus: Record<string, 'stream' | 'finish' | 'abort'>;
66
66
  handleSelectSession: (id: number | string) => void;
67
67
  }
68
68
 
@@ -16,6 +16,7 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
16
16
  import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
17
17
  import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
18
18
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
+ import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
19
20
 
20
21
  export interface ChatTimelineActionsDeps {
21
22
  inputValue: string;
@@ -28,7 +29,9 @@ export interface ChatTimelineActionsDeps {
28
29
  sessionId: string | null;
29
30
  appSettings: Record<string, any>;
30
31
  messageQueue: QueuedMessage[];
31
- setMessageQueue: (updater: SetStateAction<QueuedMessage[]>) => void;
32
+ /** Server-backed per-item queue ops (append/remove/reorder). */
33
+ enqueueMessage: (item: Omit<QueuedMessage, 'id'>) => void;
34
+ removeMessage: (id: string) => void;
32
35
  executeSend: (text: string, attachments: Attachment[], options?: { model?: QueuedMessageModel | null }) => Promise<void>;
33
36
  steerOmpAgent: (text: string, attachments: Attachment[]) => Promise<void>;
34
37
  ompAgent: OmpAgentHandle;
@@ -46,6 +49,9 @@ export interface ChatTimelineActionsDeps {
46
49
  /** Live composer model/thinking mirror, snapshotted onto queued items so
47
50
  * auto-delivery replays the exact settings. */
48
51
  composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
52
+ /** Picks made while a turn streams: held back so they cannot re-target the
53
+ * in-flight answer, then pushed onto the session before the next prompt. */
54
+ deferredComposerPickRef: DeferredModelStore;
49
55
  /** Global access-control mode, snapshotted onto queued items. */
50
56
  accessModeRef: { current: ApprovalMode };
51
57
  setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
@@ -74,7 +80,8 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
74
80
  sessionId,
75
81
  appSettings,
76
82
  messageQueue,
77
- setMessageQueue,
83
+ enqueueMessage,
84
+ removeMessage,
78
85
  executeSend,
79
86
  steerOmpAgent,
80
87
  ompAgent,
@@ -86,6 +93,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
86
93
  pendingComposerModelRef,
87
94
  pendingThinkingLevelRef,
88
95
  composerModelRef,
96
+ deferredComposerPickRef,
89
97
  accessModeRef,
90
98
  setSearchParams,
91
99
  } = deps;
@@ -123,41 +131,36 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
123
131
  return;
124
132
  }
125
133
  const composerPick = composerModelRef.current;
126
- const queuedItem: QueuedMessage = {
127
- id: `queue-${Date.now()}`,
128
- text: textToSend,
129
- attachments,
130
- // Snapshot the composer's model/thinking plus the live access mode so
131
- // auto-delivery runs the item with exactly these settings.
132
- model: composerPick
133
- ? { ...composerPick, accessMode: accessModeRef.current }
134
- : null,
135
- };
136
- // Both modes: hold the follow-up in the client queue. The panel is the
137
- // source of truth (editable, removable, survives reload via the
138
- // session-state blob); delivery happens when the run ends — the
139
- // auto-process effect sends the head item. For omp this replaces the
140
- // old immediate `follow_up` RPC, whose server-side queue the client
141
- // could never cancel (Stop kept executing it).
134
+ // Snapshot the composer's model/thinking plus the live access mode so
135
+ // server-side auto-delivery runs the item with exactly these settings.
136
+ const model = composerPick
137
+ ? { ...composerPick, accessMode: accessModeRef.current }
138
+ : null;
139
+ // Both modes: the item is stored server-side (`queued_messages`) and the
140
+ // panel is a view of it. Delivery happens when the run ends — the
141
+ // wrapper's terminal `agent_end` claims the head and dispatches it.
142
142
  setInputValue('');
143
- setMessageQueue(prev => [...prev, queuedItem]);
143
+ enqueueMessage({ text: textToSend, attachments, model });
144
144
  return;
145
145
  }
146
146
 
147
147
  setInputValue('');
148
148
  executeSend(textToSend, attachments);
149
- }, [inputValue, isGenerating, executeSend, setMessageQueue, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef]);
149
+ }, [inputValue, isGenerating, executeSend, enqueueMessage, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef]);
150
150
 
151
151
  const handleEditQueueItem = useCallback((item: QueuedMessage) => {
152
- setMessageQueue(q => q.filter(i => i.id !== item.id));
152
+ // Lift the text into the composer and drop the row. If the user never
153
+ // re-submits, the delete (below) already removed it — the old flow had a
154
+ // dead window here where removing from the local list was the only edit.
153
155
  setInputValue(item.text);
154
156
  setInputAttachments(item.attachments);
155
- }, [setMessageQueue, setInputValue, setInputAttachments]);
157
+ removeMessage(item.id);
158
+ }, [removeMessage, setInputValue, setInputAttachments]);
156
159
 
157
160
  const handleSendNowQueueItem = useCallback(async (item: QueuedMessage) => {
158
- // Explicit delivery also lifts the Stop hold.
161
+ // Explicit delivery also lifts the Stop hold (mock path).
159
162
  stopHoldRef.current = false;
160
- setMessageQueue(q => q.filter(i => i.id !== item.id));
163
+ removeMessage(item.id);
161
164
 
162
165
  if (isGenerating) {
163
166
  if (isOmpSession) {
@@ -173,7 +176,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
173
176
  } else {
174
177
  executeSend(item.text, item.attachments, { model: item.model });
175
178
  }
176
- }, [isGenerating, executeSend, setMessageQueue, isOmpSession, steerOmpAgent, abortControllerRef, setGenerating, stopHoldRef]);
179
+ }, [isGenerating, executeSend, removeMessage, isOmpSession, steerOmpAgent, abortControllerRef, setGenerating, stopHoldRef]);
177
180
 
178
181
  const handleUndo = useCallback(async (msgId: string, content?: string): Promise<boolean> => {
179
182
  if (isGenerating) {
@@ -287,16 +290,32 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
287
290
  pendingThinkingLevelRef.current = level;
288
291
  return;
289
292
  }
293
+ // A pick made mid-run is intent for the NEXT prompt: pushing it now would
294
+ // re-target the turn already streaming (omp applies set_thinking_level to
295
+ // the running turn's next LLM call).
296
+ if (isGenerating) {
297
+ stashComposerPick(deferredComposerPickRef, { thinkingLevel: level });
298
+ return;
299
+ }
300
+ consumeComposerPick(deferredComposerPickRef, { thinkingLevel: level });
290
301
  void ompAgent.setThinkingLevel(level);
291
- }, [isOmpSession, ompAgent, pendingThinkingLevelRef]);
302
+ }, [isGenerating, isOmpSession, ompAgent, pendingThinkingLevelRef, deferredComposerPickRef]);
292
303
 
293
304
  const handleModelChange = useCallback((provider: string, modelId: string) => {
294
305
  if (!isOmpSession) {
295
306
  pendingComposerModelRef.current = { provider, modelId };
296
307
  return;
297
308
  }
298
- void ompAgent.setModel(provider, modelId);
299
- }, [isOmpSession, ompAgent, pendingComposerModelRef]);
309
+ // Same rule as the thinking pick: a mid-run model change is held back so
310
+ // the answer being streamed finishes on the model it started with, and is
311
+ // applied right before the next prompt this composer sends.
312
+ if (isGenerating) {
313
+ stashComposerPick(deferredComposerPickRef, { provider, modelId });
314
+ return;
315
+ }
316
+ consumeComposerPick(deferredComposerPickRef, { provider, modelId });
317
+ void applyComposerPick(ompAgent, { provider, modelId });
318
+ }, [isGenerating, isOmpSession, ompAgent, pendingComposerModelRef, deferredComposerPickRef]);
300
319
 
301
320
  return {
302
321
  handleSend,