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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- 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 {
|
|
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
|
-
/**
|
|
24
|
-
|
|
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
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
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) =>
|
|
44
|
+
.then((snapshot) => send({ type: 'btw_state', state: snapshot }))
|
|
107
45
|
.catch((error: unknown) => {
|
|
108
|
-
|
|
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 =
|
|
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 =
|
|
123
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|