ompchamber 3.1.1 → 3.3.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 (151) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +306 -298
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/cli/lib/output.js +15 -8
  8. package/src/client/components/common/Modal.tsx +14 -4
  9. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  10. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  11. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  12. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  13. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  14. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  15. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  16. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  18. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  20. package/src/client/components/settings/Modal.tsx +45 -23
  21. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  22. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  23. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  24. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  25. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  26. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  27. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  28. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  29. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  30. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  31. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  32. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  33. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  35. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  36. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  38. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  39. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  40. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  42. package/src/client/components/settings/master-detail/index.tsx +68 -0
  43. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  45. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
  46. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  55. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  56. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  57. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  58. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  59. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  60. package/src/client/components/workspace/editor/index.tsx +15 -7
  61. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  62. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  63. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  64. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  65. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  66. package/src/client/data/theme/terminal.ts +25 -8
  67. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  68. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  69. package/src/client/hooks/chat/subagent/index.ts +37 -11
  70. package/src/client/hooks/editor/font.ts +53 -0
  71. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  72. package/src/client/hooks/settings/master-detail.ts +33 -0
  73. package/src/client/hooks/ui/clipboard.ts +17 -0
  74. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  75. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  76. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  77. package/src/client/hooks/ui/text-overflow.ts +78 -0
  78. package/src/client/hooks/ui/touch-device.ts +46 -0
  79. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  80. package/src/client/main.tsx +1 -0
  81. package/src/server/index.ts +4 -3
  82. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  83. package/src/server/lib/assets/conditional.server.ts +54 -0
  84. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  85. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  86. package/src/server/lib/assets/fonts.server.ts +9 -4
  87. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  88. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  89. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  90. package/src/server/lib/db/client.ts +5 -1
  91. package/src/server/lib/fs/git-diff.ts +34 -10
  92. package/src/server/lib/fs/git-repos.ts +81 -0
  93. package/src/server/lib/lifecycle/flock.ts +199 -0
  94. package/src/server/lib/lifecycle/identity.ts +5 -1
  95. package/src/server/lib/lifecycle/listener.ts +29 -0
  96. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  97. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  98. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  99. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  100. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  101. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  102. package/src/server/lib/observer-ws.ts +150 -0
  103. package/src/server/lib/omp/rpc/constants.ts +9 -0
  104. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  105. package/src/server/lib/omp/session/file-scan.ts +7 -1
  106. package/src/server/lib/omp/session/files.ts +95 -96
  107. package/src/server/lib/omp/session/locator.ts +18 -8
  108. package/src/server/lib/omp/session/messages.ts +7 -7
  109. package/src/server/lib/omp/session/scan.ts +108 -0
  110. package/src/server/lib/omp/session/stats.ts +50 -50
  111. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  112. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  113. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  114. package/src/server/lib/terminal/runtime.server.ts +3 -0
  115. package/src/server/lib/terminal/session.server.ts +30 -3
  116. package/src/server/plugins/shell.server.ts +52 -13
  117. package/src/server/plugins/ssr.ts +43 -7
  118. package/src/server/plugins/static.ts +51 -18
  119. package/src/server/routes/agent/command.test.ts +69 -0
  120. package/src/server/routes/agent/command.ts +14 -0
  121. package/src/server/routes/agent/ws.test.ts +159 -0
  122. package/src/server/routes/agent/ws.ts +22 -97
  123. package/src/server/routes/btw/ws.ts +22 -95
  124. package/src/server/routes/fs/git.ts +61 -70
  125. package/src/server/routes/omp/session-stats.ts +1 -1
  126. package/src/server/routes/telemetry/tokens.ts +6 -4
  127. package/src/server/routes/terminal/run.ts +4 -8
  128. package/src/server/routes/terminal/ws.ts +24 -2
  129. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  130. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  131. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  132. package/src/shared/lib/code/editor/typography.ts +86 -0
  133. package/src/shared/lib/code/language.ts +146 -0
  134. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  135. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  136. package/src/shared/lib/fonts/NOTICE.md +44 -0
  137. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  138. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  139. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  140. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  141. package/src/shared/lib/fs/diff-parser.ts +67 -42
  142. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  143. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  144. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  145. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  146. package/src/shared/lib/settings/diff.test.ts +1 -1
  147. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  148. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  149. package/src/shared/types/omp/subagent.ts +6 -0
  150. package/src/shared/types/settings/state.ts +7 -16
  151. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -12,105 +12,43 @@
12
12
  * A client that attaches replays nothing: the socket's first `btw_state` is
13
13
  * the session's current history, so a reload lands on the same panel a live
14
14
  * stream would have produced.
15
+ *
16
+ * **Per-connection state lives on `ws.data`, not in a `WeakMap` keyed by the
17
+ * `ws` object** — Elysia's Bun adapter hands `pong` the raw socket and a fresh
18
+ * wrapper to every other callback, so a WeakMap lookup always missed and the
19
+ * second heartbeat closed a healthy socket. See `@/server/lib/observer-ws`.
15
20
  */
16
21
 
17
22
  import { Elysia, t } from 'elysia';
18
- import { createEventCoalescer, type EventCoalescer } from '@/server/lib/sse';
19
- import { STREAM_HEARTBEAT_MS } from '@/shared/lib/workspace/refresh-cadence';
23
+ import { attachObserverStream, createObserverState, type ObserverConnectionState } from '@/server/lib/observer-ws';
20
24
  import { btwStateFor, subscribeBtw } from '@/server/lib/btw/registry.server';
21
25
  import type { BtwFrame } from '@/shared/types';
22
26
 
23
- /** Coalesce frames once this many bytes sit unsent. */
24
- const HIGH_WATER_BYTES = 256 * 1024;
25
-
26
- /** Longest a coalesced frame may wait before it is flushed anyway. */
27
- const FLUSH_DELAY_MS = 50;
28
-
29
- interface ConnectionState {
30
- closed: boolean;
31
- unsubscribe: (() => void) | null;
32
- heartbeat: ReturnType<typeof setInterval> | null;
33
- awaitingPong: boolean;
34
- coalescer: EventCoalescer | null;
35
- }
36
-
37
- const connections = new WeakMap<object, ConnectionState>();
27
+ /** Elysia's ws context, narrowed to the per-connection store this route adds. */
28
+ type BtwWsData = { params: { sessionId: string }; observer?: ObserverConnectionState };
38
29
 
39
30
  export const btwWsRoutes = new Elysia({ prefix: '/api/btw' }).ws('/:sessionId/ws', {
40
31
  params: t.Object({ sessionId: t.String() }),
41
32
 
42
33
  open(ws) {
43
- const sessionId = ws.data.params.sessionId;
44
- const state: ConnectionState = { closed: false, unsubscribe: null, heartbeat: null, awaitingPong: false, coalescer: null };
45
- connections.set(ws, state);
46
- // Unchecked: Elysia exposes the server socket untyped. Both members are
47
- // optional, so an absent one degrades (no byte count / no keepalive)
48
- // instead of failing.
49
- const raw = ws.raw as { bufferedAmount?: number; ping?: () => void };
50
-
51
- const teardown = () => {
52
- if (state.closed) return;
53
- state.closed = true;
54
- state.coalescer?.dispose();
55
- state.coalescer = null;
56
- if (state.heartbeat !== null) {
57
- clearInterval(state.heartbeat);
58
- state.heartbeat = null;
59
- }
60
- if (state.unsubscribe) {
61
- try {
62
- state.unsubscribe();
63
- } catch {
64
- // Subscriber cleanup is best-effort.
65
- }
66
- state.unsubscribe = null;
67
- }
68
- try {
69
- ws.close();
70
- } catch {
71
- // Already gone.
72
- }
73
- };
74
-
75
- state.coalescer = createEventCoalescer({
76
- send: (_event, data) => {
77
- try {
78
- ws.send(JSON.stringify(data));
79
- } catch {
80
- teardown();
81
- }
82
- },
83
- isBackpressured: () => (raw.bufferedAmount ?? 0) > HIGH_WATER_BYTES,
84
- flushDelayMs: FLUSH_DELAY_MS,
85
- });
86
-
87
- state.heartbeat = setInterval(() => {
88
- if (state.closed) return;
89
- if (state.awaitingPong) {
90
- teardown();
91
- return;
92
- }
93
- state.awaitingPong = true;
94
- if (!state.coalescer?.flush()) return;
95
- try {
96
- raw.ping?.();
97
- } catch {
98
- teardown();
99
- }
100
- }, STREAM_HEARTBEAT_MS);
101
-
102
- const push = (frame: BtwFrame) => state.coalescer?.push('', frame);
103
- state.unsubscribe = subscribeBtw(sessionId, push);
104
- push({ type: 'connected', sessionId });
34
+ const data = ws.data as unknown as BtwWsData;
35
+ const sessionId = data.params.sessionId;
36
+ const state = createObserverState();
37
+ data.observer = state;
38
+ const { push } = attachObserverStream(state, ws);
39
+
40
+ const send = (frame: BtwFrame) => push('', frame);
41
+ state.unsubscribe = subscribeBtw(sessionId, send);
42
+ send({ type: 'connected', sessionId });
105
43
  void btwStateFor(sessionId)
106
- .then((snapshot) => push({ type: 'btw_state', state: snapshot }))
44
+ .then((snapshot) => send({ type: 'btw_state', state: snapshot }))
107
45
  .catch((error: unknown) => {
108
- push({ type: 'btw_error', topicId: null, message: error instanceof Error ? error.message : String(error) });
46
+ send({ type: 'btw_error', topicId: null, message: error instanceof Error ? error.message : String(error) });
109
47
  });
110
48
  },
111
49
 
112
50
  pong(ws) {
113
- const state = connections.get(ws);
51
+ const state = (ws.data as unknown as BtwWsData).observer;
114
52
  if (state) state.awaitingPong = false;
115
53
  },
116
54
 
@@ -119,18 +57,7 @@ export const btwWsRoutes = new Elysia({ prefix: '/api/btw' }).ws('/:sessionId/ws
119
57
  },
120
58
 
121
59
  close(ws) {
122
- const state = connections.get(ws);
123
- if (!state || state.closed) return;
124
- state.closed = true;
125
- state.coalescer?.dispose();
126
- state.coalescer = null;
127
- if (state.heartbeat !== null) clearInterval(state.heartbeat);
128
- if (state.unsubscribe) {
129
- try {
130
- state.unsubscribe();
131
- } catch {
132
- // Subscriber cleanup is best-effort.
133
- }
134
- }
60
+ const state = (ws.data as unknown as BtwWsData).observer;
61
+ if (state) state.teardown();
135
62
  },
136
63
  });
@@ -1,65 +1,26 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `GET /api/fs/git` — the Source Control panel's data, and the file-diff /
8
+ * commit-history endpoints the diff panel and commit modal read.
9
+ *
10
+ * Repository discovery (`?reposOnly=1`) is a background walk over the scoped
11
+ * root; see `lib/fs/git-repos.ts`. This module owns the request shapes.
12
+ */
13
+
1
14
  import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
2
15
  import fs from 'fs';
3
16
  import path from 'path';
4
17
  import { resolveRoot } from '@/server/lib/fs/root';
5
18
  import { runShell } from '@/server/lib/fs/shell';
19
+ import { discoveredRepos, rescanRepos, startRepoScan } from '@/server/lib/fs/git-repos';
6
20
  import { fetchFileDiff, fetchGitCommits } from '@/server/lib/fs/git-log';
7
21
  import { fetchWorkingFileDiff } from '@/server/lib/fs/git-diff';
8
22
  import { gitSyncCount, invalidateRemoteRefs, markRemoteRefsFresh, refreshRemoteRefs } from '@/server/lib/fs/git-sync';
9
23
 
10
- const MAX_GIT_DEPTH = 8;
11
-
12
- async function getRepos(rootDir: string): Promise<string[]> {
13
- try {
14
- // Search deep enough to discover nested/child git repos inside a monorepo
15
- // workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
16
- const result = await runShell(
17
- `find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
18
- { cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
19
- );
20
- const discovered = result.stdout
21
- .trim()
22
- .split('\n')
23
- .filter(Boolean)
24
- .map(line => {
25
- const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
26
- return cleaned === '.git' || !cleaned ? '.' : cleaned;
27
- });
28
-
29
- const uniqueRepos = Array.from(new Set(discovered));
30
- if (uniqueRepos.includes('.')) {
31
- return ['.', ...uniqueRepos.filter(r => r !== '.')];
32
- }
33
- return uniqueRepos.length ? uniqueRepos : ['.'];
34
- } catch {
35
- return ['.'];
36
- }
37
- }
38
-
39
- // Nested-repo discovery is expensive (find over a deep workspace), so it runs
40
- // in the background after the loader returns the root status. Results are
41
- // cached per scoped root and the client polls `?reposOnly=1` until ready.
42
- interface RepoDiscovery {
43
- repos: string[];
44
- done: boolean;
45
- }
46
- const repoDiscovery = new Map<string, RepoDiscovery>();
47
-
48
- function startRepoScan(rootDir: string): void {
49
- if (repoDiscovery.has(rootDir)) return;
50
- const d: RepoDiscovery = { repos: ['.'], done: false };
51
- repoDiscovery.set(rootDir, d);
52
- void getRepos(rootDir)
53
- .then(repos => { d.repos = repos; d.done = true; })
54
- .catch(() => { d.repos = ['.']; d.done = true; });
55
- }
56
-
57
- function discoveredRepos(rootDir: string): { repos: string[]; pending: boolean } {
58
- const d = repoDiscovery.get(rootDir);
59
- if (!d) return { repos: ['.'], pending: false };
60
- return { repos: d.done ? d.repos : ['.'], pending: !d.done };
61
- }
62
-
63
24
  export async function loader({ request }: LoaderFunctionArgs) {
64
25
  const url = new URL(request.url);
65
26
  const rootDir = await resolveRoot(url.searchParams.get('root'), process.cwd());
@@ -68,10 +29,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
68
29
  // whatever is already cached. `rescan=1` invalidates the cache so a fresh
69
30
  // discovery pass starts (used by the repo-switcher refresh button).
70
31
  const rescan = url.searchParams.get('rescan') === '1';
71
- if (rescan) {
72
- repoDiscovery.delete(rootDir);
73
- }
74
- startRepoScan(rootDir);
32
+ if (rescan) rescanRepos(rootDir);
33
+ else startRepoScan(rootDir);
75
34
  const { repos, pending } = discoveredRepos(rootDir);
76
35
 
77
36
  // Lightweight polling endpoint: just the discovered repos, no git status.
@@ -98,7 +57,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
98
57
  if (targetFile) {
99
58
  const staged = url.searchParams.get('staged') === '1' || url.searchParams.get('staged') === 'true';
100
59
  const statusParam = url.searchParams.get('status') || undefined;
101
- const diffData = await fetchWorkingFileDiff(targetDir, targetFile, staged, statusParam);
60
+ const fullContext = url.searchParams.get('fullContext') === '1';
61
+ const diffData = await fetchWorkingFileDiff(targetDir, targetFile, staged, statusParam, fullContext);
102
62
  return json({ success: true, ...diffData });
103
63
  }
104
64
  }
@@ -133,23 +93,54 @@ export async function loader({ request }: LoaderFunctionArgs) {
133
93
  const statusOut = (await runShell('git status --porcelain=v1 -uall', { cwd: targetDir, maxBuffer: 1024 * 1024 })).stdout;
134
94
 
135
95
  let branch = 'main';
136
- let branches: string[] = ['main'];
137
- let remoteBranches: string[] = [];
96
+ // Seeded empty, not `['main']`: the seed used to be pushed as a real local
97
+ // branch, so a repository whose only branch is `main` listed it twice.
98
+ const branches: string[] = [];
99
+ const remoteBranches: string[] = [];
138
100
  try {
139
- const branchOut = await runShell('git rev-parse --abbrev-ref HEAD', { cwd: targetDir });
140
- branch = branchOut.stdout.trim() || 'main';
141
-
142
- const localOut = await runShell('git branch --format="%(refname:short)"', { cwd: targetDir });
143
- branches = localOut.stdout.trim().split('\n').filter(Boolean);
144
-
145
- const remoteOut = await runShell('git branch -r --format="%(refname:short)"', { cwd: targetDir });
146
- remoteBranches = remoteOut.stdout.trim().split('\n').filter(Boolean).filter(b => b.includes('/') && !b.endsWith('/HEAD'));
147
- if (!branches.includes(branch)) {
148
- branches.unshift(branch);
101
+ // One `for-each-ref` answers all three questions the three previous
102
+ // spawns asked separately (`rev-parse --abbrev-ref HEAD`, `branch`,
103
+ // `branch -r`): `%(HEAD)` marks the checked-out branch with `*`, and the
104
+ // two refspecs cover local and remote in one listing. Measured 8.15 ms
105
+ // against 19.63 ms for the three-call form, on a 5s poll.
106
+ //
107
+ // Classification reads the FULL refname: `%(refname:short)` maps
108
+ // `refs/remotes/origin/HEAD` to the bare `origin`, which has no slash and
109
+ // would otherwise be listed as a local branch.
110
+ const refsOut = (await runShell(
111
+ 'git for-each-ref --format="%(refname)%09%(refname:short)%09%(HEAD)" refs/heads refs/remotes',
112
+ { cwd: targetDir },
113
+ )).stdout;
114
+ let sawHead = false;
115
+ for (const line of refsOut.split('\n')) {
116
+ const [full, short, head] = line.split('\t');
117
+ if (!full || !short) continue;
118
+ // `refs/remotes/origin/HEAD` is a symbolic alias, not a branch.
119
+ if (full.endsWith('/HEAD')) continue;
120
+ if (full.startsWith('refs/remotes/')) {
121
+ remoteBranches.push(short);
122
+ continue;
123
+ }
124
+ branches.push(short);
125
+ if (head === '*') {
126
+ branch = short;
127
+ sawHead = true;
128
+ }
129
+ }
130
+ // Detached HEAD: no local ref is marked, and the branch is the sha
131
+ // `rev-parse --abbrev-ref HEAD` prints. Ask for it rather than reporting
132
+ // a branch the checkout is not on.
133
+ if (!sawHead) {
134
+ const headOut = await runShell('git rev-parse --abbrev-ref HEAD', { cwd: targetDir });
135
+ branch = headOut.stdout.trim() || 'main';
149
136
  }
150
137
  } catch {
151
138
  // ignore branch resolution errors
152
139
  }
140
+ // `main` remains the answer only when nothing could be read (a non-repo
141
+ // directory, git missing) — a real listing always supplies its own refs.
142
+ if (branches.length === 0) branches.push('main');
143
+ else if (!branches.includes(branch)) branches.unshift(branch);
153
144
 
154
145
  const changes = statusOut
155
146
  .split('\n')
@@ -22,7 +22,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
22
22
  const resolved = await resolveSessionFileOr404(sessionId);
23
23
  if ('response' in resolved) return resolved.response;
24
24
  const { filePath } = resolved;
25
- const stats = readSessionStats(filePath);
25
+ const stats = await readSessionStats(filePath);
26
26
  if (!stats) {
27
27
  return json({ error: 'Session could not be parsed' }, { status: 500 });
28
28
  }
@@ -4,7 +4,7 @@ import { methodNotAllowed } from '@/server/lib/route-adapter';
4
4
  import { BREAKDOWN_DATA, MOCK_CHART_SERIES, MOCK_CUSTOM_RANGE_DATA, MOCK_RANGE_DATA, TIME_RANGES } from '@/client/data/mock/token-usage';
5
5
  import { isMockMode } from '@/server/mock.server';
6
6
  import { loadModelsDevCatalog } from '@/shared/lib/models/catalog';
7
- import { aggregateUsage, type UsageWindow } from '@/server/lib/omp/session/usage/aggregate';
7
+ import { aggregateUsageWindows, PRESET_RANGES, type UsageWindow } from '@/server/lib/omp/session/usage/aggregate';
8
8
  import { buildChartSeries, toBreakdownRows, toMetricSet } from '@/server/lib/omp/session/usage/shaping';
9
9
  import type { BreakdownRow, BreakdownTab, CadenceType, ChartSeriesPoint, TimeRangeType, TokenUsageMetricSet } from '@/shared/types';
10
10
 
@@ -22,7 +22,6 @@ const REAL_ZERO_METRIC: TokenUsageMetricSet = {
22
22
  usageRecords: 0,
23
23
  };
24
24
 
25
- const PRESET_RANGES: Exclude<TimeRangeType, 'custom'>[] = ['today', '7d', '30d', '90d', 'all'];
26
25
  const CADENCES: CadenceType[] = ['daily', 'weekly', 'monthly'];
27
26
  const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
28
27
 
@@ -104,12 +103,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
104
103
 
105
104
  // Real Mode: aggregate actual token/cost usage from omp session files.
106
105
  try {
106
+ // One scan, every window: the tree read is the expensive part (measured
107
+ // 795 ms over 455 transcripts), and folding six windows from one scan
108
+ // replaced six separate reads — 3.3 s cold on this machine.
109
+ const { ranges, active: activeAggregate } = await aggregateUsageWindows(window);
107
110
  const realRangeData = {} as Record<Exclude<TimeRangeType, 'custom'>, TokenUsageMetricSet>;
108
111
  for (const range of PRESET_RANGES) {
109
- realRangeData[range] = toMetricSet(await aggregateUsage({ kind: 'preset', range }), cadence);
112
+ realRangeData[range] = toMetricSet(ranges[range], cadence);
110
113
  }
111
114
 
112
- const activeAggregate = await aggregateUsage(window);
113
115
  const realBreakdown: Record<BreakdownTab, BreakdownRow[]> = {
114
116
  model: toBreakdownRows(activeAggregate, 'model'),
115
117
  day: toBreakdownRows(activeAggregate, 'day', cadence),
@@ -14,18 +14,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
14
14
  const url = new URL(request.url);
15
15
  const rootDir = await resolveRoot(url.searchParams.get('root'), process.cwd());
16
16
  const targetDir = await scopeToRepo(rootDir, url.searchParams.get('repo'));
17
- let bunVersion = '';
17
+ // The server IS the Bun being reported, so the version is already in this
18
+ // process. Spawning `bun --version` through a shell cost 10.4 ms per header
19
+ // load to read a constant `Bun.version` answers in 0.05 ms.
20
+ const bunVersion = Bun.version;
18
21
  // `process.version` is the Node version Bun emulates, not a real Node binary.
19
22
  const nodeVersion = process.version;
20
23
  let gitBranch = 'main';
21
24
 
22
- try {
23
- const bunOut = await runShell('bun --version', { cwd: targetDir });
24
- bunVersion = bunOut.stdout.trim();
25
- } catch {
26
- bunVersion = Bun.version;
27
- }
28
-
29
25
  try {
30
26
  const branchOut = await runShell('git rev-parse --abbrev-ref HEAD', { cwd: targetDir });
31
27
  gitBranch = branchOut.stdout.trim() || 'main';
@@ -66,9 +66,24 @@ export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:te
66
66
  viewer: {
67
67
  send(frame) {
68
68
  try {
69
- ws.send(frame);
69
+ // Bun returns -1 when the frame was dropped rather than queued
70
+ // (the socket is over its high-water mark). Reporting it lets the
71
+ // session close this viewer instead of buffering a PTY's output
72
+ // into a socket nobody is reading.
73
+ return ws.send(frame) !== -1;
70
74
  } catch {
71
75
  // Socket closed mid-send; the close handler detaches.
76
+ return false;
77
+ }
78
+ },
79
+ drop() {
80
+ // The terminal session removed this viewer; close the socket so the
81
+ // client reconnects and replays the scrollback rather than sitting
82
+ // on a stream with a hole in it.
83
+ try {
84
+ ws.close();
85
+ } catch {
86
+ // Already gone.
72
87
  }
73
88
  },
74
89
  },
@@ -114,7 +129,14 @@ export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:te
114
129
  // Ordering is safe without sequence numbers: attach captured the history
115
130
  // and registered this viewer in one synchronous block, so nothing can be
116
131
  // delivered between this frame and the replay that follows it.
117
- if (replay.length > 0) ws.send(replay);
132
+ //
133
+ // The replay goes through the viewer, so a socket that cannot take the
134
+ // scrollback is closed rather than left attached to a terminal whose
135
+ // first screenful was silently dropped.
136
+ if (replay.length > 0 && !attachment.viewer.send(replay)) {
137
+ detachTerminal(attachment.viewer, attachment.id);
138
+ attachment.viewer.drop();
139
+ }
118
140
  } catch (error) {
119
141
  const code = error instanceof TerminalRuntimeError ? error.code : 'unknown';
120
142
  const message = error instanceof Error ? error.message : String(error);
@@ -27,12 +27,14 @@ function makeDeps() {
27
27
  const activity: string[] = [];
28
28
  const commandOutputs: string[] = [];
29
29
  let settledCount = 0;
30
+ let runEnds = 0;
30
31
  let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
31
32
  const callbacks: OmpAgentCallbacks = {
32
33
  onMessageEnd: (msg) => ended.push(msg),
33
34
  onActivity: (verb) => activity.push(verb),
34
35
  onPromptSettled: () => { settledCount += 1; },
35
36
  onCommandOutput: (text) => commandOutputs.push(text),
37
+ onAgentEnd: () => { runEnds += 1; },
36
38
  };
37
39
  const deps: OmpAgentFoldDeps = {
38
40
  sessionId: 's1',
@@ -47,7 +49,7 @@ function makeDeps() {
47
49
  currentThinkingLevelRef: { current: undefined },
48
50
  fileMutatingCallsRef: { current: new Set<string>() },
49
51
  };
50
- return { deps, ended, activity, commandOutputs, settled: () => settledCount, state: () => state };
52
+ return { deps, ended, activity, commandOutputs, settled: () => settledCount, runEnds: () => runEnds, state: () => state };
51
53
  }
52
54
 
53
55
  describe('toChatMessage error derivation', () => {
@@ -185,4 +187,34 @@ describe('foldAgentEvent agent_end', () => {
185
187
  foldAgentEvent({ type: 'agent_end', messages: [{ ...ABORTED_TURN, stopReason: 'end_turn' }] }, deps);
186
188
  expect(ended).toHaveLength(0);
187
189
  });
190
+
191
+ test('a non-terminal agent_end keeps the run generating and does not fire onAgentEnd', () => {
192
+ // omp's shape when the turn yields with work still alive — a detached
193
+ // subagent, a compaction, a background job. Measured on omp 18.3.2: with an
194
+ // async subagent, this frame lands ~14s BEFORE the subagent finishes, and
195
+ // treating it as the run end blanked the generating indicator for the whole
196
+ // stretch the roster was still showing a live child.
197
+ const { deps, state, runEnds } = makeDeps();
198
+ foldAgentEvent({ type: 'agent_start' }, deps);
199
+ expect(state().isGenerating).toBe(true);
200
+
201
+ foldAgentEvent({ type: 'agent_end', isTerminal: false, messages: [] }, deps);
202
+ expect(state().isGenerating).toBe(true);
203
+ expect(runEnds()).toBe(0);
204
+
205
+ // The continuation ends the run for real; only now may the UI settle.
206
+ foldAgentEvent({ type: 'agent_start' }, deps);
207
+ foldAgentEvent({ type: 'agent_end', isTerminal: true, messages: [] }, deps);
208
+ expect(state().isGenerating).toBe(false);
209
+ expect(runEnds()).toBe(1);
210
+ });
211
+
212
+ test('an agent_end with no isTerminal field is treated as terminal', () => {
213
+ // The field is optional: a frame that omits it is a normal run end.
214
+ const { deps, state, runEnds } = makeDeps();
215
+ foldAgentEvent({ type: 'agent_start' }, deps);
216
+ foldAgentEvent({ type: 'agent_end', messages: [] }, deps);
217
+ expect(state().isGenerating).toBe(false);
218
+ expect(runEnds()).toBe(1);
219
+ });
188
220
  });
@@ -225,6 +225,16 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
225
225
  }
226
226
 
227
227
  case 'agent_end': {
228
+ // A NON-TERMINAL `agent_end` is not the end of the run: omp emits it when
229
+ // the turn yields but work is still alive — a detached subagent, a
230
+ // compaction, a background job, or a stop-time reminder — and a later
231
+ // `agent_start` opens the continuation. Measured on omp 18.3.2: with an
232
+ // async subagent, `agent_end isTerminal=false` arrives ~13s BEFORE the
233
+ // subagent's terminal frame, and clearing the generating state there
234
+ // blanked the indicator for the whole stretch the roster was still
235
+ // showing a live child. The server's own fold gates on the same field
236
+ // (frame-fold.ts), so the two ends now agree.
237
+ if (data.isTerminal === false) break;
228
238
  deps.setState((prev) => ({ ...prev, isGenerating: false }));
229
239
  deps.activityRef.current = '';
230
240
  const terminal = materializeTerminalMessages(data, deps, callbacks ?? undefined);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor typography contract.
8
+ *
9
+ * These are the properties that make one set of constants serve two layouts at
10
+ * any zoom level. Each one has a silent failure mode:
11
+ *
12
+ * - A px line-height does not scale with the font size, so zooming the editor
13
+ * leaves the rows at the size they were chosen for at 12px — cramped at 24,
14
+ * loose at 8. A ratio cannot.
15
+ * - The stack must name the bundled face first: `Fira Code` is the only entry
16
+ * the app ships, so anything ahead of it would either be a font nobody has or
17
+ * silently change the ligatures the whole console is typeset in.
18
+ * - The bounds must bracket the default, or the editor opens at a size its own
19
+ * zoom controls cannot return to (the first click jumps).
20
+ */
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+
24
+ import {
25
+ EDITOR_DEFAULT_FONT_FAMILY,
26
+ EDITOR_DEFAULT_FONT_SIZE,
27
+ EDITOR_FONT_CHOICES,
28
+ EDITOR_FONT_FAMILY,
29
+ EDITOR_LINE_HEIGHT,
30
+ EDITOR_MAX_FONT_SIZE,
31
+ EDITOR_MIN_FONT_SIZE,
32
+ editorFontStack,
33
+ } from '@/shared/lib/code/editor/typography';
34
+
35
+ describe('editor typography', () => {
36
+ test('the line box is a ratio, so it scales with the font size', () => {
37
+ expect(typeof EDITOR_LINE_HEIGHT).toBe('number');
38
+ expect(EDITOR_LINE_HEIGHT).toBeGreaterThan(1);
39
+ expect(EDITOR_LINE_HEIGHT).toBeLessThan(3);
40
+ });
41
+
42
+ test('the stack leads with the bundled face and ends in a generic', () => {
43
+ expect(EDITOR_FONT_FAMILY.startsWith('"Fira Code"')).toBe(true);
44
+ expect(EDITOR_FONT_FAMILY.trimEnd().endsWith('monospace')).toBe(true);
45
+ });
46
+
47
+ test('the zoom bounds bracket the size the editor opens at', () => {
48
+ expect(EDITOR_MIN_FONT_SIZE).toBeLessThan(EDITOR_DEFAULT_FONT_SIZE);
49
+ expect(EDITOR_MAX_FONT_SIZE).toBeGreaterThan(EDITOR_DEFAULT_FONT_SIZE);
50
+ });
51
+ });
52
+
53
+ describe('editorFontStack', () => {
54
+ test('the default and an empty choice both yield the shared stack', () => {
55
+ expect(editorFontStack(EDITOR_DEFAULT_FONT_FAMILY)).toBe(EDITOR_FONT_FAMILY);
56
+ expect(editorFontStack('')).toBe(EDITOR_FONT_FAMILY);
57
+ expect(editorFontStack(undefined)).toBe(EDITOR_FONT_FAMILY);
58
+ expect(editorFontStack(' ')).toBe(EDITOR_FONT_FAMILY);
59
+ });
60
+
61
+ test('a chosen face is preferred, with the bundled stack still behind it', () => {
62
+ const stack = editorFontStack('JetBrains Mono');
63
+ expect(stack.startsWith('"JetBrains Mono"')).toBe(true);
64
+ // The fallback is what keeps an uninstalled choice from breaking the editor.
65
+ expect(stack).toContain(EDITOR_FONT_FAMILY);
66
+ expect(stack.trimEnd().endsWith('monospace')).toBe(true);
67
+ });
68
+
69
+ test('a multi-word choice is quoted, so it stays one family name', () => {
70
+ expect(editorFontStack('Courier New').startsWith('"Courier New"')).toBe(true);
71
+ });
72
+
73
+ test('the default choice is one the picker offers', () => {
74
+ expect(EDITOR_FONT_CHOICES.map((choice) => choice.id)).toContain(EDITOR_DEFAULT_FONT_FAMILY);
75
+ });
76
+
77
+ test('exactly one offered face is the bundled one, and it is the default', () => {
78
+ const bundled = EDITOR_FONT_CHOICES.filter((choice) => choice.bundled);
79
+ expect(bundled.map((choice) => choice.id)).toEqual([EDITOR_DEFAULT_FONT_FAMILY]);
80
+ });
81
+
82
+ test('every choice resolves to a stack that still ends in the bundled face', () => {
83
+ // The picker offers faces the app does not ship, so the guarantee that makes
84
+ // that safe is per-choice: a device without the chosen face must land on
85
+ // Fira Code rather than on a system serif or a blank cell.
86
+ for (const choice of EDITOR_FONT_CHOICES) {
87
+ const stack = editorFontStack(choice.id);
88
+ expect(stack.startsWith(`"${choice.id}"`)).toBe(true);
89
+ expect(stack).toContain(EDITOR_FONT_FAMILY);
90
+ expect(stack.trimEnd().endsWith('monospace')).toBe(true);
91
+ }
92
+ });
93
+ });