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
|
@@ -31,12 +31,29 @@ interface PendingChange {
|
|
|
31
31
|
* which is what this used to do — left the row painted red/green and counted in
|
|
32
32
|
* `+n/-n`, so the toggle changed the glyphs and nothing else.
|
|
33
33
|
*/
|
|
34
|
-
export
|
|
34
|
+
export interface ParseDiffOptions {
|
|
35
|
+
/** Fold `-`/`+` pairs that differ only in whitespace back into context. */
|
|
36
|
+
ignoreWhitespace?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Which view the caller renders. Both views are built from one flush so a
|
|
39
|
+
* decision cannot land in one and not the other, but building the one nobody
|
|
40
|
+
* shows doubled the allocations on a full-context diff — an 80k-line file
|
|
41
|
+
* produced ~60k discarded `SplitDiffRow` objects. `'both'` is the default so
|
|
42
|
+
* the counts and the shared flush stay exactly as they were.
|
|
43
|
+
*/
|
|
44
|
+
views?: 'unified' | 'split' | 'both';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseUnifiedDiff(rawDiff: string, options: ParseDiffOptions = {}): {
|
|
35
48
|
lines: DiffLine[];
|
|
36
49
|
splitRows: SplitDiffRow[];
|
|
37
50
|
additions: number;
|
|
38
51
|
deletions: number;
|
|
39
52
|
} {
|
|
53
|
+
const ignoreWhitespace = options.ignoreWhitespace ?? false;
|
|
54
|
+
const views = options.views ?? 'both';
|
|
55
|
+
const wantUnified = views !== 'split';
|
|
56
|
+
const wantSplit = views !== 'unified';
|
|
40
57
|
const lines: DiffLine[] = [];
|
|
41
58
|
const splitRows: SplitDiffRow[] = [];
|
|
42
59
|
let additions = 0;
|
|
@@ -73,43 +90,47 @@ export function parseUnifiedDiff(rawDiff: string, ignoreWhitespace: boolean = fa
|
|
|
73
90
|
}
|
|
74
91
|
}
|
|
75
92
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
if (wantUnified) {
|
|
94
|
+
for (let i = 0; i < delBuffer.length; i++) {
|
|
95
|
+
const del = delBuffer[i];
|
|
96
|
+
const add = addBuffer[i];
|
|
97
|
+
if (folded[i] && add) {
|
|
98
|
+
// Show it as unchanged on both sides: the change is still in the file,
|
|
99
|
+
// the reader asked not to be shown formatting noise.
|
|
100
|
+
lines.push({
|
|
101
|
+
type: 'context',
|
|
102
|
+
// The new form, so the row reads as the file's current state.
|
|
103
|
+
text: add.text,
|
|
104
|
+
oldLineNumber: del.lineNumber,
|
|
105
|
+
newLineNumber: add.lineNumber,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
lines.push({ type: 'del', text: del.text, oldLineNumber: del.lineNumber });
|
|
110
|
+
}
|
|
111
|
+
for (let i = 0; i < addBuffer.length; i++) {
|
|
112
|
+
const add = addBuffer[i];
|
|
113
|
+
if (folded[i]) continue;
|
|
114
|
+
lines.push({ type: 'add', text: add.text, newLineNumber: add.lineNumber });
|
|
90
115
|
}
|
|
91
|
-
lines.push({ type: 'del', text: del.text, oldLineNumber: del.lineNumber });
|
|
92
|
-
}
|
|
93
|
-
for (let i = 0; i < addBuffer.length; i++) {
|
|
94
|
-
const add = addBuffer[i];
|
|
95
|
-
if (folded[i]) continue;
|
|
96
|
-
lines.push({ type: 'add', text: add.text, newLineNumber: add.lineNumber });
|
|
97
116
|
}
|
|
98
117
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
if (wantSplit) {
|
|
119
|
+
for (let i = 0; i < maxLen; i++) {
|
|
120
|
+
const del = delBuffer[i];
|
|
121
|
+
const add = addBuffer[i];
|
|
122
|
+
if (folded[i] && del && add) {
|
|
123
|
+
splitRows.push({
|
|
124
|
+
left: { type: 'context', text: del.text, lineNumber: del.lineNumber },
|
|
125
|
+
right: { type: 'context', text: add.text, lineNumber: add.lineNumber },
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
103
129
|
splitRows.push({
|
|
104
|
-
left: { type: '
|
|
105
|
-
right: { type: '
|
|
130
|
+
left: del ? { type: 'del', text: del.text, lineNumber: del.lineNumber } : undefined,
|
|
131
|
+
right: add ? { type: 'add', text: add.text, lineNumber: add.lineNumber } : undefined,
|
|
106
132
|
});
|
|
107
|
-
continue;
|
|
108
133
|
}
|
|
109
|
-
splitRows.push({
|
|
110
|
-
left: del ? { type: 'del', text: del.text, lineNumber: del.lineNumber } : undefined,
|
|
111
|
-
right: add ? { type: 'add', text: add.text, lineNumber: add.lineNumber } : undefined,
|
|
112
|
-
});
|
|
113
134
|
}
|
|
114
135
|
delBuffer = [];
|
|
115
136
|
addBuffer = [];
|
|
@@ -156,16 +177,20 @@ export function parseUnifiedDiff(rawDiff: string, ignoreWhitespace: boolean = fa
|
|
|
156
177
|
} else {
|
|
157
178
|
flushBuffers();
|
|
158
179
|
const text = rawLine.startsWith(' ') ? rawLine.slice(1) : rawLine;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
if (wantUnified) {
|
|
181
|
+
lines.push({
|
|
182
|
+
type: 'context',
|
|
183
|
+
text,
|
|
184
|
+
oldLineNumber: oldCounter,
|
|
185
|
+
newLineNumber: newCounter,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (wantSplit) {
|
|
189
|
+
splitRows.push({
|
|
190
|
+
left: { type: 'context', text, lineNumber: oldCounter },
|
|
191
|
+
right: { type: 'context', text, lineNumber: newCounter },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
169
194
|
oldCounter++;
|
|
170
195
|
newCounter++;
|
|
171
196
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Covers `readSubagentProgressFrame`, the one reader every subagent-progress
|
|
8
|
+
* consumer goes through (sidebar roster, transcript hook, server liveness).
|
|
9
|
+
*
|
|
10
|
+
* The wrapper shape is the whole point: omp's `subagent_progress` frame nests
|
|
11
|
+
* the child's `AgentProgress` under `payload.progress` and puts NO `id` on the
|
|
12
|
+
* wrapper, so a reader that parses the wrapper alone yields an id-less object
|
|
13
|
+
* with none of the reported fields — silently, because every field is
|
|
14
|
+
* optional. The payload below is a verbatim frame captured from omp 18.3.2.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { parseSubagentProgress, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
|
|
20
|
+
|
|
21
|
+
/** One `subagent_progress` frame as omp emits it (trimmed to the read fields). */
|
|
22
|
+
const REAL_FRAME = {
|
|
23
|
+
index: 0,
|
|
24
|
+
agent: 'scout',
|
|
25
|
+
agentSource: 'user',
|
|
26
|
+
task: 'Complete assignment thoroughly:\n\n# Target\nCount files.',
|
|
27
|
+
parentToolCallId: 'call_00_LsrwnITatwhVYX5NElPf2544',
|
|
28
|
+
detached: true,
|
|
29
|
+
assignment: '# Target\nCount files.',
|
|
30
|
+
sessionFile: '/tmp/sessions/parent/CountTsx.jsonl',
|
|
31
|
+
progress: {
|
|
32
|
+
index: 0,
|
|
33
|
+
id: 'CountTsx',
|
|
34
|
+
agent: 'scout',
|
|
35
|
+
agentSource: 'user',
|
|
36
|
+
status: 'running',
|
|
37
|
+
task: 'Complete assignment thoroughly:\n\n# Target\nCount files.',
|
|
38
|
+
recentTools: [],
|
|
39
|
+
recentOutput: [],
|
|
40
|
+
toolCount: 3,
|
|
41
|
+
requests: 2,
|
|
42
|
+
tokens: 10400,
|
|
43
|
+
cost: 0.003,
|
|
44
|
+
durationMs: 8700,
|
|
45
|
+
modelRole: 'smol',
|
|
46
|
+
resolvedModelIdentity: 'kenari/deepseek-v4-flash',
|
|
47
|
+
resolvedThinkingLevel: 'low',
|
|
48
|
+
resolvedModel: 'kenari/deepseek-v4-flash:low',
|
|
49
|
+
resolvedModelIsFallback: false,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('readSubagentProgressFrame', () => {
|
|
54
|
+
test('unwraps the nested AgentProgress and keeps the fields it reports', () => {
|
|
55
|
+
const progress = readSubagentProgressFrame(REAL_FRAME);
|
|
56
|
+
expect(progress?.id).toBe('CountTsx');
|
|
57
|
+
expect(progress?.resolvedModel).toBe('kenari/deepseek-v4-flash:low');
|
|
58
|
+
expect(progress?.resolvedThinkingLevel).toBe('low');
|
|
59
|
+
expect(progress?.status).toBe('running');
|
|
60
|
+
expect(progress?.tokens).toBe(10400);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('reads a bare AgentProgress payload (snapshot field, not a frame)', () => {
|
|
64
|
+
const progress = readSubagentProgressFrame(REAL_FRAME.progress);
|
|
65
|
+
expect(progress?.id).toBe('CountTsx');
|
|
66
|
+
expect(progress?.resolvedThinkingLevel).toBe('low');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('keeps the wrapper id when the nested snapshot omits it', () => {
|
|
70
|
+
const progress = readSubagentProgressFrame({ id: 'Wrapped', progress: { status: 'running' } });
|
|
71
|
+
expect(progress?.id).toBe('Wrapped');
|
|
72
|
+
expect(progress?.status).toBe('running');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('returns undefined for a payload carrying no progress fields at all', () => {
|
|
76
|
+
expect(readSubagentProgressFrame({ event: { type: 'message_end' } })).toBeUndefined();
|
|
77
|
+
expect(readSubagentProgressFrame(null)).toBeUndefined();
|
|
78
|
+
expect(readSubagentProgressFrame('nonsense')).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('parseSubagentProgress', () => {
|
|
83
|
+
test('carries the resolved thinking level and drops a non-string one', () => {
|
|
84
|
+
expect(parseSubagentProgress({ resolvedThinkingLevel: 'max' })?.resolvedThinkingLevel).toBe('max');
|
|
85
|
+
expect(parseSubagentProgress({ resolvedThinkingLevel: null })?.resolvedThinkingLevel).toBeUndefined();
|
|
86
|
+
expect(parseSubagentProgress({ resolvedThinkingLevel: 3 })?.resolvedThinkingLevel).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -81,6 +81,8 @@ export function parseSubagentProgress(value: unknown): SubagentProgress | undefi
|
|
|
81
81
|
if (modelRole !== undefined) out.modelRole = modelRole;
|
|
82
82
|
const resolvedModel = asString(value.resolvedModel);
|
|
83
83
|
if (resolvedModel !== undefined) out.resolvedModel = resolvedModel;
|
|
84
|
+
const resolvedThinkingLevel = asString(value.resolvedThinkingLevel);
|
|
85
|
+
if (resolvedThinkingLevel !== undefined) out.resolvedThinkingLevel = resolvedThinkingLevel;
|
|
84
86
|
if (typeof value.resolvedModelIsFallback === 'boolean') out.resolvedModelIsFallback = value.resolvedModelIsFallback;
|
|
85
87
|
if (isRecord(value.retryState)) {
|
|
86
88
|
const attempt = asNumber(value.retryState.attempt);
|
|
@@ -180,6 +182,21 @@ export function parseSubagentLifecycle(value: unknown): SubagentInfo | undefined
|
|
|
180
182
|
return info;
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Read a progress frame's payload: `SubagentProgressPayload` nests the
|
|
187
|
+
* AgentProgress it reports under `progress`, while a bare AgentProgress (and a
|
|
188
|
+
* `get_subagents` snapshot's own field) is the object itself. Both shapes reach
|
|
189
|
+
* the same listeners, so unwrap here rather than at each call site — the outer
|
|
190
|
+
* wrapper's `id`/`index` fill in what the nested snapshot omits.
|
|
191
|
+
*/
|
|
192
|
+
export function readSubagentProgressFrame(payload: unknown): SubagentProgress | undefined {
|
|
193
|
+
const direct = parseSubagentProgress(payload);
|
|
194
|
+
if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
|
|
195
|
+
const nested = parseSubagentProgress(payload.progress);
|
|
196
|
+
if (!nested) return direct;
|
|
197
|
+
return { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index };
|
|
198
|
+
}
|
|
199
|
+
|
|
183
200
|
/** Extract a compact live-activity entry from a subagent_event payload. */
|
|
184
201
|
export function parseSubagentActivityEvent(value: unknown): SubagentActivityEvent | null {
|
|
185
202
|
if (!isRecord(value)) return null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Covers the subagent transcript conversion's thinking-level stamping.
|
|
8
|
+
*
|
|
9
|
+
* A subagent transcript records its level as a separate `thinking_level_change`
|
|
10
|
+
* entry, never as a per-message field, so `convertMessages` has to walk the
|
|
11
|
+
* records and stamp the level in effect — otherwise the footer has no level to
|
|
12
|
+
* show and drops the segment entirely. The child's own level is the only
|
|
13
|
+
* correct one: a subagent routinely runs at a different level than the chat
|
|
14
|
+
* that spawned it, so borrowing the parent's would mislabel every row.
|
|
15
|
+
*
|
|
16
|
+
* Also pins the non-message entry handling the walk sits beside: a change
|
|
17
|
+
* record must be consumed, not converted into a row.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from 'bun:test';
|
|
21
|
+
|
|
22
|
+
import { convertMessages, mergeMessages } from '@/shared/lib/omp/subagent/transcript-client';
|
|
23
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
24
|
+
|
|
25
|
+
/** An assistant message entry carrying text, as omp writes it to the JSONL. */
|
|
26
|
+
function assistantEntry(id: string, text: string): Record<string, unknown> {
|
|
27
|
+
return {
|
|
28
|
+
type: 'message',
|
|
29
|
+
id,
|
|
30
|
+
timestamp: 1790403839189,
|
|
31
|
+
message: {
|
|
32
|
+
role: 'assistant',
|
|
33
|
+
model: 'deepseek-v4-flash',
|
|
34
|
+
provider: 'kenari',
|
|
35
|
+
content: [{ type: 'text', text }],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function changeEntry(level: unknown): Record<string, unknown> {
|
|
41
|
+
return { type: 'thinking_level_change', id: `chg-${String(level)}`, thinkingLevel: level, configured: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('convertMessages thinking-level stamping', () => {
|
|
45
|
+
test('stamps the level in effect onto assistant rows', () => {
|
|
46
|
+
const rows = convertMessages([changeEntry('low'), assistantEntry('a1', 'first'), assistantEntry('a2', 'second')], false);
|
|
47
|
+
expect(rows.map((row) => row.thinkingLevel)).toEqual(['low', 'low']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('follows a mid-transcript change, so each turn keeps the level that served it', () => {
|
|
51
|
+
const rows = convertMessages([
|
|
52
|
+
changeEntry('low'),
|
|
53
|
+
assistantEntry('a1', 'first'),
|
|
54
|
+
changeEntry('high'),
|
|
55
|
+
assistantEntry('a2', 'second'),
|
|
56
|
+
], false);
|
|
57
|
+
expect(rows[0].thinkingLevel).toBe('low');
|
|
58
|
+
expect(rows[1].thinkingLevel).toBe('high');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('normalizes a null level record to "off" rather than dropping the segment', () => {
|
|
62
|
+
const rows = convertMessages([changeEntry(null), assistantEntry('a1', 'first')], false);
|
|
63
|
+
expect(rows[0].thinkingLevel).toBe('off');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('leaves user rows unstamped and the level absent before any record', () => {
|
|
67
|
+
const rows = convertMessages([
|
|
68
|
+
{ type: 'message', id: 'u1', message: { role: 'user', content: 'hi' } },
|
|
69
|
+
assistantEntry('a1', 'answer'),
|
|
70
|
+
changeEntry('max'),
|
|
71
|
+
assistantEntry('a2', 'later'),
|
|
72
|
+
], false);
|
|
73
|
+
expect(rows[0].role).toBe('user');
|
|
74
|
+
expect(rows[0].thinkingLevel).toBeUndefined();
|
|
75
|
+
expect(rows[1].thinkingLevel).toBeUndefined();
|
|
76
|
+
expect(rows[2].thinkingLevel).toBe('max');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('consumes the change record instead of turning it into a row', () => {
|
|
80
|
+
const rows = convertMessages([changeEntry('low')], false);
|
|
81
|
+
expect(rows).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('mergeMessages', () => {
|
|
86
|
+
test('replaces a streamed row in place so its stamped level survives finalization', () => {
|
|
87
|
+
const streamed = convertMessages([changeEntry('low'), assistantEntry('a1', 'partial')], true);
|
|
88
|
+
const final = convertMessages([changeEntry('low'), assistantEntry('a1', 'complete answer')], false);
|
|
89
|
+
const merged = mergeMessages(streamed, final);
|
|
90
|
+
expect(merged).toHaveLength(1);
|
|
91
|
+
expect(merged[0].content).toBe('complete answer');
|
|
92
|
+
expect(merged[0].thinkingLevel).toBe('low');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('appends a row whose id is new', () => {
|
|
96
|
+
const prev: ChatMessageData[] = convertMessages([changeEntry('low'), assistantEntry('a1', 'first')], false);
|
|
97
|
+
const merged = mergeMessages(prev, convertMessages([changeEntry('low'), assistantEntry('a2', 'second')], false));
|
|
98
|
+
expect(merged.map((row) => row.id)).toEqual(['a1', 'a2']);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { ChatMessageData, SubagentMessagesPage } from '@/shared/types';
|
|
14
14
|
import { toChatMessage } from '@/shared/lib/omp/session/mapper';
|
|
15
15
|
import { extractText } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
16
|
+
import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
|
|
16
17
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
17
18
|
|
|
18
19
|
/** Normalize one wire message: the RPC may hand back bare omp AgentMessages
|
|
@@ -29,9 +30,20 @@ function normalizeRawMessage(value: unknown): Record<string, unknown> | null {
|
|
|
29
30
|
|
|
30
31
|
export function convertMessages(raw: unknown[], streaming: boolean): ChatMessageData[] {
|
|
31
32
|
const out: ChatMessageData[] = [];
|
|
33
|
+
// omp records the thinking level as its own `thinking_level_change` entry
|
|
34
|
+
// (session state), not as a per-message field. Walk the records in order and
|
|
35
|
+
// stamp the level in effect onto each non-user turn, so a subagent footer
|
|
36
|
+
// shows the level that served that turn — the child's own level, never the
|
|
37
|
+
// parent session's. Mirrors the server's main-session loader
|
|
38
|
+
// (`loadSessionMessages`).
|
|
39
|
+
let currentLevel: string | undefined;
|
|
32
40
|
for (const item of raw) {
|
|
33
41
|
if (isRecord(item)) {
|
|
34
42
|
const entryType = typeof item.type === 'string' ? item.type : '';
|
|
43
|
+
if (entryType === 'thinking_level_change') {
|
|
44
|
+
currentLevel = normalizeThinkingLevel(item.thinkingLevel);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
35
47
|
if (entryType === 'custom' || entryType === 'custom_message') {
|
|
36
48
|
const customType = typeof item.customType === 'string' ? item.customType : '';
|
|
37
49
|
if (customType === 'session_exit') {
|
|
@@ -77,7 +89,11 @@ export function convertMessages(raw: unknown[], streaming: boolean): ChatMessage
|
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
const converted = toChatMessage(record, streaming);
|
|
80
|
-
if (converted)
|
|
92
|
+
if (!converted) continue;
|
|
93
|
+
if (currentLevel !== undefined && converted.role !== 'user') {
|
|
94
|
+
converted.thinkingLevel = currentLevel;
|
|
95
|
+
}
|
|
96
|
+
out.push(converted);
|
|
81
97
|
}
|
|
82
98
|
return out;
|
|
83
99
|
}
|
|
@@ -4,7 +4,7 @@ import { diffSettings } from '@/shared/lib/settings/diff';
|
|
|
4
4
|
|
|
5
5
|
describe('diffSettings', () => {
|
|
6
6
|
test('an object patch persists only the keys it actually changed', () => {
|
|
7
|
-
const prev = { theme: 'paper', soundAlerts: true,
|
|
7
|
+
const prev = { theme: 'paper', soundAlerts: true, editorFont: 'Fira Code' };
|
|
8
8
|
const next = { ...prev, theme: 'nord-dark' };
|
|
9
9
|
expect(diffSettings(prev, next, { theme: 'nord-dark' })).toEqual({ theme: 'nord-dark' });
|
|
10
10
|
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bytes the terminal key bar sends.
|
|
8
|
+
*
|
|
9
|
+
* Every expectation here is copied from xterm's own `evaluateKeyboardEvent`
|
|
10
|
+
* (`@xterm/xterm/src/common/input/Keyboard.ts`), because the shell cannot tell a
|
|
11
|
+
* bar button from a physical key: a sequence that merely looks plausible works
|
|
12
|
+
* in bash and breaks in vim. Three of those rules are the ones this file exists
|
|
13
|
+
* to pin — a modifier is a CSI PARAMETER and not an ESC prefix, application
|
|
14
|
+
* cursor mode swaps in the SS3 spelling, and Ctrl on a literal key is a latch
|
|
15
|
+
* for typed text rather than a byte to fold.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
NO_MODIFIERS,
|
|
22
|
+
TERMINAL_KEYS,
|
|
23
|
+
TERMINAL_SYMBOLS,
|
|
24
|
+
applyModifiers,
|
|
25
|
+
ctrlCharacter,
|
|
26
|
+
resolveKeySequence,
|
|
27
|
+
type TerminalModifiers,
|
|
28
|
+
} from '@/shared/lib/workspace/terminal/keys';
|
|
29
|
+
|
|
30
|
+
const CTRL: TerminalModifiers = { ctrl: true, alt: false };
|
|
31
|
+
const ALT: TerminalModifiers = { ctrl: false, alt: true };
|
|
32
|
+
|
|
33
|
+
describe('resolveKeySequence — cursor keys', () => {
|
|
34
|
+
test('unmodified arrows use CSI', () => {
|
|
35
|
+
expect(resolveKeySequence('up')).toBe('\x1b[A');
|
|
36
|
+
expect(resolveKeySequence('down')).toBe('\x1b[B');
|
|
37
|
+
expect(resolveKeySequence('right')).toBe('\x1b[C');
|
|
38
|
+
expect(resolveKeySequence('left')).toBe('\x1b[D');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('application cursor mode swaps in the SS3 spelling', () => {
|
|
42
|
+
const options = { applicationCursorKeys: true };
|
|
43
|
+
expect(resolveKeySequence('up', options)).toBe('\x1bOA');
|
|
44
|
+
expect(resolveKeySequence('left', options)).toBe('\x1bOD');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('Home and End always use the terminfo spelling, DECCKM or not', () => {
|
|
48
|
+
// `TERM=xterm-256color` declares khome/kend as SS3, so the CSI form is a
|
|
49
|
+
// sequence the shell does not bind: zsh rings the bell and ignores it.
|
|
50
|
+
expect(resolveKeySequence('home')).toBe('\x1bOH');
|
|
51
|
+
expect(resolveKeySequence('end')).toBe('\x1bOF');
|
|
52
|
+
expect(resolveKeySequence('home', { applicationCursorKeys: true })).toBe('\x1bOH');
|
|
53
|
+
expect(resolveKeySequence('end', { applicationCursorKeys: true })).toBe('\x1bOF');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a modifier beats application cursor mode, as xterm does', () => {
|
|
57
|
+
const options = { applicationCursorKeys: true, modifiers: CTRL };
|
|
58
|
+
expect(resolveKeySequence('up', options)).toBe('\x1b[1;5A');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a modifier is a CSI parameter, never an ESC prefix', () => {
|
|
62
|
+
expect(resolveKeySequence('up', { modifiers: CTRL })).toBe('\x1b[1;5A');
|
|
63
|
+
expect(resolveKeySequence('up', { modifiers: ALT })).toBe('\x1b[1;3A');
|
|
64
|
+
expect(resolveKeySequence('up', { modifiers: { ctrl: true, alt: true } })).toBe('\x1b[1;7A');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('resolveKeySequence — keys with a fixed spelling', () => {
|
|
69
|
+
test('an unmodified page/delete key drops its leading 1', () => {
|
|
70
|
+
expect(resolveKeySequence('delete')).toBe('\x1b[3~');
|
|
71
|
+
expect(resolveKeySequence('pageUp')).toBe('\x1b[5~');
|
|
72
|
+
expect(resolveKeySequence('pageDown')).toBe('\x1b[6~');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('page keys carry their prefix once modified', () => {
|
|
76
|
+
expect(resolveKeySequence('pageUp', { modifiers: CTRL })).toBe('\x1b[5;5~');
|
|
77
|
+
expect(resolveKeySequence('delete', { modifiers: ALT })).toBe('\x1b[3;3~');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a page key has no SS3 form, so DECCKM does not touch it', () => {
|
|
81
|
+
expect(resolveKeySequence('pageUp', { applicationCursorKeys: true })).toBe('\x1b[5~');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('escape, tab and shift-tab are literal', () => {
|
|
85
|
+
expect(resolveKeySequence('escape')).toBe('\x1b');
|
|
86
|
+
expect(resolveKeySequence('tab')).toBe('\t');
|
|
87
|
+
expect(resolveKeySequence('shiftTab')).toBe('\x1b[Z');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('resolveKeySequence — modifiers on a literal key', () => {
|
|
92
|
+
test('Ctrl does NOT fold a literal key into a control byte', () => {
|
|
93
|
+
// The `^C` button is the only Ctrl+C. A tapped `|` stays a pipe: folding it
|
|
94
|
+
// would send FS (0x1c), which is not a pipe and not a signal.
|
|
95
|
+
expect(resolveKeySequence('pipe', { modifiers: CTRL })).toBe('|');
|
|
96
|
+
expect(resolveKeySequence('underscore', { modifiers: CTRL })).toBe('_');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('Alt on a literal key is the ESC prefix', () => {
|
|
100
|
+
expect(resolveKeySequence('pipe', { modifiers: ALT })).toBe('\x1b|');
|
|
101
|
+
expect(resolveKeySequence('escape', { modifiers: ALT })).toBe('\x1b\x1b');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the dedicated control keys are already the folded byte', () => {
|
|
105
|
+
expect(resolveKeySequence('interrupt')).toBe('\x03');
|
|
106
|
+
expect(resolveKeySequence('eof')).toBe('\x04');
|
|
107
|
+
expect(resolveKeySequence('clear')).toBe('\x0c');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('resolveKeySequence — defaults and coverage', () => {
|
|
112
|
+
test('no options means no modifier and no application mode', () => {
|
|
113
|
+
// `pageUp` is the plain CSI default; Home/End carry a terminfo spelling.
|
|
114
|
+
expect(resolveKeySequence('pageUp')).toBe('\x1b[5~');
|
|
115
|
+
expect(NO_MODIFIERS).toEqual({ ctrl: false, alt: false });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('every control key resolves to bytes', () => {
|
|
119
|
+
for (const def of TERMINAL_KEYS) {
|
|
120
|
+
expect(resolveKeySequence(def.id).length, def.id).toBeGreaterThan(0);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('every symbol resolves to its own literal', () => {
|
|
125
|
+
for (const def of TERMINAL_SYMBOLS) {
|
|
126
|
+
expect(resolveKeySequence(def.id), def.id).toBe(def.sequence);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('the symbol layer covers the characters a phone buries', () => {
|
|
131
|
+
const labels = new Set(TERMINAL_SYMBOLS.map((def) => def.label));
|
|
132
|
+
for (const required of ['|', '~', '\\', '$', '*', '?', '&', '<', '>', '`', '{', '}', '[', ']', '"', "'"]) {
|
|
133
|
+
expect(labels.has(required), required).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a symbol under a latched Alt is an ESC prefix, like any literal', () => {
|
|
138
|
+
expect(resolveKeySequence('dollar', { modifiers: ALT })).toBe('\x1b$');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an unknown id sends nothing rather than a guess', () => {
|
|
142
|
+
expect(resolveKeySequence('nope' as never)).toBe('');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('ctrlCharacter', () => {
|
|
147
|
+
test('letters fold case-insensitively onto C0', () => {
|
|
148
|
+
expect(ctrlCharacter('c')).toBe('\x03');
|
|
149
|
+
expect(ctrlCharacter('C')).toBe('\x03');
|
|
150
|
+
expect(ctrlCharacter('a')).toBe('\x01');
|
|
151
|
+
expect(ctrlCharacter('z')).toBe('\x1a');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the symbol set matches the terminal, not the ASCII table', () => {
|
|
155
|
+
expect(ctrlCharacter('@')).toBe('\x00');
|
|
156
|
+
expect(ctrlCharacter(' ')).toBe('\x00');
|
|
157
|
+
expect(ctrlCharacter('[')).toBe('\x1b');
|
|
158
|
+
expect(ctrlCharacter('_')).toBe('\x1f');
|
|
159
|
+
expect(ctrlCharacter('?')).toBe('\x7f');
|
|
160
|
+
expect(ctrlCharacter('!')).toBeNull();
|
|
161
|
+
expect(ctrlCharacter('ab')).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('applyModifiers', () => {
|
|
166
|
+
test('a latched Ctrl folds a typed letter', () => {
|
|
167
|
+
expect(applyModifiers('c', CTRL)).toBe('\x03');
|
|
168
|
+
expect(applyModifiers('r', CTRL)).toBe('\x12');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a latched Alt prefixes ESC', () => {
|
|
172
|
+
expect(applyModifiers('b', ALT)).toBe('\x1bb');
|
|
173
|
+
expect(applyModifiers('f', { ctrl: true, alt: true })).toBe('\x1b\x06');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('no modifier is the identity', () => {
|
|
177
|
+
expect(applyModifiers('c', NO_MODIFIERS)).toBe('c');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a paste is left intact — the latch does not corrupt it', () => {
|
|
181
|
+
const pasted = 'bun run build\n';
|
|
182
|
+
expect(applyModifiers(pasted, CTRL)).toBe(pasted);
|
|
183
|
+
expect(applyModifiers(pasted, ALT)).toBe(pasted);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a character with no control code passes through', () => {
|
|
187
|
+
expect(applyModifiers('!', CTRL)).toBe('!');
|
|
188
|
+
});
|
|
189
|
+
});
|