ompchamber 3.1.0 → 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 (124) hide show
  1. package/README.md +6 -3
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +279 -280
  4. package/dist/client/static/js/index-01dv383b.css +1 -0
  5. package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/output.js +15 -8
  12. package/src/cli/lib/stop-scope.js +23 -0
  13. package/src/cli/lib/stop-scope.test.js +41 -0
  14. package/src/client/components/common/Modal.tsx +14 -4
  15. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  16. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  17. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  18. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  19. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  20. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  21. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  22. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  23. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  24. package/src/client/components/settings/Modal.tsx +35 -6
  25. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  26. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  27. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  28. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  29. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  30. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  31. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  32. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  33. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  34. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  35. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  38. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  39. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  40. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  42. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  43. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  44. package/src/client/components/settings/master-detail/index.tsx +68 -0
  45. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  47. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  57. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  58. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  59. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  60. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  61. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  62. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  63. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  64. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  65. package/src/client/hooks/chat/subagent/index.ts +37 -11
  66. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  67. package/src/client/hooks/settings/master-detail.ts +33 -0
  68. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  69. package/src/server/lib/bundler/css.test.ts +87 -0
  70. package/src/server/lib/bundler/css.ts +17 -2
  71. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  72. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  73. package/src/server/lib/db/client.ts +5 -1
  74. package/src/server/lib/fs/git-diff.ts +34 -10
  75. package/src/server/lib/fs/git-repos.ts +81 -0
  76. package/src/server/lib/lifecycle/flock.ts +199 -0
  77. package/src/server/lib/lifecycle/identity.ts +5 -1
  78. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  79. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  80. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  81. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  82. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  83. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  84. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  85. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  86. package/src/server/lib/lifecycle/restart.ts +9 -14
  87. package/src/server/lib/observer-ws.ts +150 -0
  88. package/src/server/lib/omp/rpc/constants.ts +9 -0
  89. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  90. package/src/server/lib/omp/session/file-scan.ts +7 -1
  91. package/src/server/lib/omp/session/files.ts +95 -96
  92. package/src/server/lib/omp/session/locator.ts +18 -8
  93. package/src/server/lib/omp/session/messages.ts +7 -7
  94. package/src/server/lib/omp/session/scan.ts +108 -0
  95. package/src/server/lib/omp/session/stats.ts +50 -50
  96. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  97. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  98. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  99. package/src/server/lib/terminal/runtime.server.ts +3 -0
  100. package/src/server/lib/terminal/session.server.ts +30 -3
  101. package/src/server/routes/agent/command.test.ts +69 -0
  102. package/src/server/routes/agent/command.ts +14 -0
  103. package/src/server/routes/agent/ws.test.ts +159 -0
  104. package/src/server/routes/agent/ws.ts +22 -97
  105. package/src/server/routes/btw/ws.ts +22 -95
  106. package/src/server/routes/fs/git.ts +61 -70
  107. package/src/server/routes/omp/session-stats.ts +1 -1
  108. package/src/server/routes/telemetry/tokens.ts +6 -4
  109. package/src/server/routes/terminal/run.ts +4 -8
  110. package/src/server/routes/terminal/ws.ts +24 -2
  111. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  112. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  113. package/src/shared/lib/code/language.ts +146 -0
  114. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  115. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  116. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  117. package/src/shared/lib/fs/diff-parser.ts +67 -42
  118. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  119. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  120. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  121. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  122. package/src/shared/types/omp/subagent.ts +6 -0
  123. package/dist/client/static/js/index-n4wkqj7t.css +0 -1
  124. package/src/server/lib/lifecycle/restart.test.ts +0 -21
@@ -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
@@ -61,7 +61,7 @@ describe('parseUnifiedDiff', () => {
61
61
  });
62
62
 
63
63
  test('folds a whitespace-only rewrite into context when ignoring whitespace', () => {
64
- const { lines, splitRows, additions, deletions } = parseUnifiedDiff(WHITESPACE_ONLY, true);
64
+ const { lines, splitRows, additions, deletions } = parseUnifiedDiff(WHITESPACE_ONLY, { ignoreWhitespace: true });
65
65
 
66
66
  // The counts the toolbar renders must match what is on screen.
67
67
  expect(additions).toBe(0);
@@ -81,7 +81,7 @@ describe('parseUnifiedDiff', () => {
81
81
  });
82
82
 
83
83
  test('keeps a real change when ignoring whitespace', () => {
84
- const { lines, additions, deletions } = parseUnifiedDiff(REAL_CHANGE, true);
84
+ const { lines, additions, deletions } = parseUnifiedDiff(REAL_CHANGE, { ignoreWhitespace: true });
85
85
 
86
86
  expect(additions).toBe(1);
87
87
  expect(deletions).toBe(1);
@@ -90,7 +90,7 @@ describe('parseUnifiedDiff', () => {
90
90
  });
91
91
 
92
92
  test('folds only the whitespace pair of a mixed hunk', () => {
93
- const { lines, additions, deletions } = parseUnifiedDiff(MIXED, true);
93
+ const { lines, additions, deletions } = parseUnifiedDiff(MIXED, { ignoreWhitespace: true });
94
94
 
95
95
  // Line 1 differs by indentation only; line 2 is a genuine edit.
96
96
  expect(additions).toBe(1);
@@ -110,7 +110,7 @@ describe('parseUnifiedDiff', () => {
110
110
  });
111
111
 
112
112
  test('returns empty results for an empty diff', () => {
113
- expect(parseUnifiedDiff('', true)).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
114
- expect(parseUnifiedDiff(' \n', false)).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
113
+ expect(parseUnifiedDiff('', { ignoreWhitespace: true })).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
114
+ expect(parseUnifiedDiff(' \n', { ignoreWhitespace: false })).toEqual({ lines: [], splitRows: [], additions: 0, deletions: 0 });
115
115
  });
116
116
  });
@@ -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 function parseUnifiedDiff(rawDiff: string, ignoreWhitespace: boolean = false): {
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
- for (let i = 0; i < delBuffer.length; i++) {
77
- const del = delBuffer[i];
78
- const add = addBuffer[i];
79
- if (folded[i] && add) {
80
- // Show it as unchanged on both sides: the change is still in the file,
81
- // the reader asked not to be shown formatting noise.
82
- lines.push({
83
- type: 'context',
84
- // The new form, so the row reads as the file's current state.
85
- text: add.text,
86
- oldLineNumber: del.lineNumber,
87
- newLineNumber: add.lineNumber,
88
- });
89
- continue;
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
- for (let i = 0; i < maxLen; i++) {
100
- const del = delBuffer[i];
101
- const add = addBuffer[i];
102
- if (folded[i] && del && add) {
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: 'context', text: del.text, lineNumber: del.lineNumber },
105
- right: { type: 'context', text: add.text, lineNumber: add.lineNumber },
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
- lines.push({
160
- type: 'context',
161
- text,
162
- oldLineNumber: oldCounter,
163
- newLineNumber: newCounter,
164
- });
165
- splitRows.push({
166
- left: { type: 'context', text, lineNumber: oldCounter },
167
- right: { type: 'context', text, lineNumber: newCounter },
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;