ompchamber 3.1.1 → 3.2.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 (109) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +279 -280
  3. package/dist/client/static/js/index-01dv383b.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/cli/lib/output.js +15 -8
  7. package/src/client/components/common/Modal.tsx +14 -4
  8. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  10. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  11. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  12. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  13. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  15. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  16. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  17. package/src/client/components/settings/Modal.tsx +35 -6
  18. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  19. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  20. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  21. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  22. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  23. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  24. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  25. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  26. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  27. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  28. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  29. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  30. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  31. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  32. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  33. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  35. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/master-detail/index.tsx +68 -0
  38. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  41. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  50. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  51. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  52. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  53. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  54. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  55. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  56. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  57. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  58. package/src/client/hooks/chat/subagent/index.ts +37 -11
  59. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  60. package/src/client/hooks/settings/master-detail.ts +33 -0
  61. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  62. package/src/server/lib/db/client.ts +5 -1
  63. package/src/server/lib/fs/git-diff.ts +34 -10
  64. package/src/server/lib/fs/git-repos.ts +81 -0
  65. package/src/server/lib/lifecycle/flock.ts +199 -0
  66. package/src/server/lib/lifecycle/identity.ts +5 -1
  67. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  68. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  69. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  70. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  71. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  72. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  73. package/src/server/lib/observer-ws.ts +150 -0
  74. package/src/server/lib/omp/rpc/constants.ts +9 -0
  75. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  76. package/src/server/lib/omp/session/file-scan.ts +7 -1
  77. package/src/server/lib/omp/session/files.ts +95 -96
  78. package/src/server/lib/omp/session/locator.ts +18 -8
  79. package/src/server/lib/omp/session/messages.ts +7 -7
  80. package/src/server/lib/omp/session/scan.ts +108 -0
  81. package/src/server/lib/omp/session/stats.ts +50 -50
  82. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  83. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  84. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  85. package/src/server/lib/terminal/runtime.server.ts +3 -0
  86. package/src/server/lib/terminal/session.server.ts +30 -3
  87. package/src/server/routes/agent/command.test.ts +69 -0
  88. package/src/server/routes/agent/command.ts +14 -0
  89. package/src/server/routes/agent/ws.test.ts +159 -0
  90. package/src/server/routes/agent/ws.ts +22 -97
  91. package/src/server/routes/btw/ws.ts +22 -95
  92. package/src/server/routes/fs/git.ts +61 -70
  93. package/src/server/routes/omp/session-stats.ts +1 -1
  94. package/src/server/routes/telemetry/tokens.ts +6 -4
  95. package/src/server/routes/terminal/run.ts +4 -8
  96. package/src/server/routes/terminal/ws.ts +24 -2
  97. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  98. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  99. package/src/shared/lib/code/language.ts +146 -0
  100. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  101. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  102. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  103. package/src/shared/lib/fs/diff-parser.ts +67 -42
  104. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  105. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  106. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  107. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  108. package/src/shared/types/omp/subagent.ts +6 -0
  109. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -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,146 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Language detection for the highlighter: which Shiki grammar a path maps to,
8
+ * and whether a block of text looks like code at all.
9
+ *
10
+ * Both are pure string predicates over the input, with no highlighter state, so
11
+ * they live beside the tokenizer rather than inside it — the tokenizer needs a
12
+ * booted Shiki instance, these do not, and a caller that only wants to pick a
13
+ * language (a tool card deciding whether to render a code surface) does not
14
+ * have to pull one in.
15
+ */
16
+
17
+ /** Map file extension to Shiki language id */
18
+ export function getLanguageFromPath(filePath?: string): string {
19
+ if (!filePath) return 'javascript';
20
+ const clean = filePath.split('?')[0].split('#')[0].replace(/:\d+(?:-\d+)?$/, '');
21
+ const ext = clean.split('.').pop()?.toLowerCase();
22
+ switch (ext) {
23
+ case 'ts': return 'typescript';
24
+ case 'tsx': return 'tsx';
25
+ case 'js':
26
+ case 'mjs':
27
+ case 'cjs': return 'javascript';
28
+ case 'jsx': return 'jsx';
29
+ case 'json': return 'json';
30
+ case 'md':
31
+ case 'markdown': return 'markdown';
32
+ case 'css': return 'css';
33
+ case 'sh':
34
+ case 'bash':
35
+ case 'zsh': return 'bash';
36
+ case 'diff':
37
+ case 'patch': return 'diff';
38
+ case 'yaml':
39
+ case 'yml': return 'yaml';
40
+ case 'html': return 'html';
41
+ case 'svg':
42
+ case 'xml': return 'xml';
43
+ case 'py': return 'python';
44
+ case 'go': return 'go';
45
+ case 'rs': return 'rust';
46
+ case 'sql': return 'sql';
47
+ case 'toml': return 'toml';
48
+ case 'java': return 'java';
49
+ case 'c':
50
+ case 'h': return 'c';
51
+ case 'cpp':
52
+ case 'cc':
53
+ case 'hpp': return 'cpp';
54
+ case 'cs': return 'csharp';
55
+ case 'rb': return 'ruby';
56
+ case 'php': return 'php';
57
+ case 'kt':
58
+ case 'kts': return 'kotlin';
59
+ case 'swift': return 'swift';
60
+ case 'scala': return 'scala';
61
+ case 'lua': return 'lua';
62
+ case 'pl': return 'perl';
63
+ case 'dockerfile': return 'dockerfile';
64
+ case 'ini':
65
+ case 'cfg':
66
+ case 'conf': return 'ini';
67
+ case 'properties': return 'properties';
68
+ case 'env': return 'bash';
69
+ case 'zig': return 'zig';
70
+ case 'ex':
71
+ case 'exs': return 'elixir';
72
+ case 'dart': return 'dart';
73
+ case 'ml':
74
+ case 'mli': return 'ocaml';
75
+ case 'hs': return 'haskell';
76
+ case 'nix': return 'nix';
77
+ case 'tf':
78
+ case 'tfvars':
79
+ case 'hcl': return 'hcl';
80
+ case 'graphql':
81
+ case 'gql': return 'graphql';
82
+ case 'sol': return 'solidity';
83
+ case 'r': return 'r';
84
+ case 'jl': return 'julia';
85
+ case 'erl': return 'erlang';
86
+ case 'clj':
87
+ case 'cljs':
88
+ case 'cljc':
89
+ case 'edn': return 'clojure';
90
+ case 'scm':
91
+ case 'ss': return 'scheme';
92
+ case 'rkt': return 'racket';
93
+ case 'proto': return 'protobuf';
94
+ case 'makefile':
95
+ case 'mk': return 'makefile';
96
+ case 'ps1':
97
+ case 'psm1': return 'powershell';
98
+ case 'json5': return 'json5';
99
+ case 'less': return 'less';
100
+ case 'scss': return 'scss';
101
+ case 'sass': return 'sass';
102
+ case 'styl': return 'stylus';
103
+ case 'vue': return 'vue';
104
+ case 'svelte': return 'svelte';
105
+ case 'astro': return 'astro';
106
+ // `.m` is genuinely ambiguous (Objective-C vs MATLAB); map to Objective-C,
107
+ // the common editor default.
108
+ case 'mm':
109
+ case 'm': return 'objective-c';
110
+ case 'groovy':
111
+ case 'gvy':
112
+ case 'gradle': return 'groovy';
113
+ default: return 'javascript';
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The patterns `isCodeLike` probes, at module scope.
119
+ *
120
+ * A regex *literal* inside the function body constructs a fresh `RegExp` on
121
+ * every call, and this runs per render from the notice, task-result and Bash
122
+ * cards. Compiled once here instead.
123
+ */
124
+ const CODE_PATTERNS = [
125
+ /^(?:import|export)\s+.+from\s+['"][^'"]+['"]/m,
126
+ /^(?:const|let|var|function|class|interface|type|enum)\s+[A-Za-z0-9_$]+/m,
127
+ /(?:=>|\bconsole\.(?:log|error|warn)|return\s+|async\s+function)/,
128
+ /^\s*\d+:\s*(?:import|const|let|function|\/\/|\/\*|<|[A-Za-z])/m,
129
+ /^\s*\[[A-Za-z0-9_./-]+\.[a-zA-Z0-9]+\]\s*\n\d+:/m,
130
+ ] as const;
131
+
132
+ /** Check if a string looks like code or structured programming text */
133
+ export function isCodeLike(text: string): boolean {
134
+ if (!text || text.length < 5) return false;
135
+ const trimmed = text.trim();
136
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
137
+ try {
138
+ JSON.parse(trimmed);
139
+ return true;
140
+ } catch {
141
+ // not valid JSON
142
+ }
143
+ }
144
+
145
+ return CODE_PATTERNS.some((pattern) => pattern.test(trimmed));
146
+ }
@@ -7,7 +7,8 @@ import { beforeAll, describe, expect, test } from 'bun:test';
7
7
 
8
8
  import { bootSyntax, onLanguageReady, requestLanguage } from '@/shared/lib/code/highlighter';
9
9
  import { findMatches } from '@/shared/lib/code/editor/find';
10
- import { getLanguageFromPath, highlightCode, highlightLines } from '@/shared/lib/code/syntax-highlight';
10
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
11
+ import { highlightCode, highlightLines } from '@/shared/lib/code/syntax-highlight';
11
12
  import { highlightCodeWindow } from '@/shared/lib/code/windowed-highlight';
12
13
 
13
14
  /** Shiki emits `<span class="shiki">…` with dual-theme CSS variables per token. */
@@ -7,128 +7,6 @@ import { SHIKI_THEMES } from '@/shared/lib/code/shiki-themes';
7
7
 
8
8
  export type { HighlightLinesOptions };
9
9
 
10
- /** Map file extension to Shiki language id */
11
- export function getLanguageFromPath(filePath?: string): string {
12
- if (!filePath) return 'javascript';
13
- const clean = filePath.split('?')[0].split('#')[0].replace(/:\d+(?:-\d+)?$/, '');
14
- const ext = clean.split('.').pop()?.toLowerCase();
15
- switch (ext) {
16
- case 'ts': return 'typescript';
17
- case 'tsx': return 'tsx';
18
- case 'js':
19
- case 'mjs':
20
- case 'cjs': return 'javascript';
21
- case 'jsx': return 'jsx';
22
- case 'json': return 'json';
23
- case 'md':
24
- case 'markdown': return 'markdown';
25
- case 'css': return 'css';
26
- case 'sh':
27
- case 'bash':
28
- case 'zsh': return 'bash';
29
- case 'diff':
30
- case 'patch': return 'diff';
31
- case 'yaml':
32
- case 'yml': return 'yaml';
33
- case 'html': return 'html';
34
- case 'svg':
35
- case 'xml': return 'xml';
36
- case 'py': return 'python';
37
- case 'go': return 'go';
38
- case 'rs': return 'rust';
39
- case 'sql': return 'sql';
40
- case 'toml': return 'toml';
41
- case 'java': return 'java';
42
- case 'c':
43
- case 'h': return 'c';
44
- case 'cpp':
45
- case 'cc':
46
- case 'hpp': return 'cpp';
47
- case 'cs': return 'csharp';
48
- case 'rb': return 'ruby';
49
- case 'php': return 'php';
50
- case 'kt':
51
- case 'kts': return 'kotlin';
52
- case 'swift': return 'swift';
53
- case 'scala': return 'scala';
54
- case 'lua': return 'lua';
55
- case 'pl': return 'perl';
56
- case 'dockerfile': return 'dockerfile';
57
- case 'ini':
58
- case 'cfg':
59
- case 'conf': return 'ini';
60
- case 'properties': return 'properties';
61
- case 'env': return 'bash';
62
- case 'zig': return 'zig';
63
- case 'ex':
64
- case 'exs': return 'elixir';
65
- case 'dart': return 'dart';
66
- case 'ml':
67
- case 'mli': return 'ocaml';
68
- case 'hs': return 'haskell';
69
- case 'nix': return 'nix';
70
- case 'tf':
71
- case 'tfvars':
72
- case 'hcl': return 'hcl';
73
- case 'graphql':
74
- case 'gql': return 'graphql';
75
- case 'sol': return 'solidity';
76
- case 'r': return 'r';
77
- case 'jl': return 'julia';
78
- case 'erl': return 'erlang';
79
- case 'clj':
80
- case 'cljs':
81
- case 'cljc':
82
- case 'edn': return 'clojure';
83
- case 'scm':
84
- case 'ss': return 'scheme';
85
- case 'rkt': return 'racket';
86
- case 'proto': return 'protobuf';
87
- case 'makefile':
88
- case 'mk': return 'makefile';
89
- case 'ps1':
90
- case 'psm1': return 'powershell';
91
- case 'json5': return 'json5';
92
- case 'less': return 'less';
93
- case 'scss': return 'scss';
94
- case 'sass': return 'sass';
95
- case 'styl': return 'stylus';
96
- case 'vue': return 'vue';
97
- case 'svelte': return 'svelte';
98
- case 'astro': return 'astro';
99
- // `.m` is genuinely ambiguous (Objective-C vs MATLAB); map to Objective-C,
100
- // the common editor default.
101
- case 'mm':
102
- case 'm': return 'objective-c';
103
- case 'groovy':
104
- case 'gvy':
105
- case 'gradle': return 'groovy';
106
- default: return 'javascript';
107
- }
108
- }
109
-
110
- /** Check if a string looks like code or structured programming text */
111
- export function isCodeLike(text: string): boolean {
112
- if (!text || text.length < 5) return false;
113
- const trimmed = text.trim();
114
- if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
115
- try {
116
- JSON.parse(trimmed);
117
- return true;
118
- } catch {
119
- // not valid JSON
120
- }
121
- }
122
-
123
- const codePatterns = [
124
- /^(?:import|export)\s+.+from\s+['"][^'"]+['"]/m,
125
- /^(?:const|let|var|function|class|interface|type|enum)\s+[A-Za-z0-9_$]+/m,
126
- /(?:=>|\bconsole\.(?:log|error|warn)|return\s+|async\s+function)/,
127
- /^\s*\d+:\s*(?:import|const|let|function|\/\/|\/\*|<|[A-Za-z])/m,
128
- /^\s*\[[A-Za-z0-9_./-]+\.[a-zA-Z0-9]+\]\s*\n\d+:/m,
129
- ];
130
- return codePatterns.some((pattern) => pattern.test(trimmed));
131
- }
132
10
 
133
11
  /**
134
12
  * Pick a language that can be tokenized *synchronously* right now. A grammar