ompchamber 0.6.0 → 0.8.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 (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import type { ToolCallData } from '@/shared/types';
9
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
10
+ import {
11
+ groupAskFrames,
12
+ parseAskQuestions,
13
+ parseAskResult,
14
+ normalizeAskText,
15
+ } from '@/shared/lib/chat/ask-questions';
16
+
17
+ function askTool(input: unknown, extra: Partial<ToolCallData> = {}): ToolCallData {
18
+ return { id: 'call_ask_1', type: 'ask', title: 'ask', name: 'ask', input: input as Record<string, unknown>, ...extra };
19
+ }
20
+
21
+ function selectFrame(id: string, title: string, options: string[] = ['A', 'B']): ExtensionUiDialogRequest {
22
+ return { type: 'extension_ui_request', id, method: 'select', title, options };
23
+ }
24
+
25
+ const THREE_QUESTIONS = {
26
+ questions: [
27
+ {
28
+ id: 'language',
29
+ header: 'Language',
30
+ question: 'Which language should we use for this test?',
31
+ options: [{ label: 'Python', description: 'Use Python for testing' }, { label: 'TypeScript' }],
32
+ recommended: 0,
33
+ },
34
+ { id: 'style', question: 'What answer style do you prefer for testing?', options: [{ label: 'Balanced' }] },
35
+ { id: 'confirm', question: 'Did this render correctly?', options: [{ label: 'Yes' }, { label: 'No' }] },
36
+ ],
37
+ };
38
+
39
+ describe('parseAskQuestions', () => {
40
+ test('reads every question, not just the first', () => {
41
+ const questions = parseAskQuestions(askTool(THREE_QUESTIONS));
42
+ expect(questions.map((q) => q.id)).toEqual(['language', 'style', 'confirm']);
43
+ expect(questions[0].options[0]).toEqual({ label: 'Python', description: 'Use Python for testing' });
44
+ expect(questions[0].recommended).toBe(0);
45
+ expect(questions[1].multi).toBe(false);
46
+ });
47
+
48
+ test('is empty for a non-ask payload, so the card falls back to the raw result', () => {
49
+ expect(parseAskQuestions(askTool({ question: 'single legacy shape' }))).toEqual([]);
50
+ expect(parseAskQuestions(askTool(undefined))).toEqual([]);
51
+ });
52
+
53
+ test('drops entries without question text instead of rendering a blank block', () => {
54
+ const questions = parseAskQuestions(askTool({ questions: [{ id: 'a' }, { id: 'b', question: ' Keep me ' }] }));
55
+ expect(questions).toEqual([expect.objectContaining({ id: 'b', question: 'Keep me' })]);
56
+ });
57
+ });
58
+
59
+ describe('normalizeAskText', () => {
60
+ test('strips the progress suffix and the multi-select prefix omp decorates titles with', () => {
61
+ expect(normalizeAskText('Which language? (2/3)')).toBe('which language?');
62
+ expect(normalizeAskText('(2 selected) Which language?')).toBe('which language?');
63
+ expect(normalizeAskText('(1 selected) Pick one (3/3)')).toBe('pick one');
64
+ });
65
+ });
66
+
67
+ describe('groupAskFrames', () => {
68
+ const questions = parseAskQuestions(askTool(THREE_QUESTIONS));
69
+
70
+ test('puts each dialog on the question that raised it', () => {
71
+ const frames = [
72
+ selectFrame('f1', 'Which language should we use for this test? (1/3)'),
73
+ selectFrame('f2', 'What answer style do you prefer for testing? (2/3)'),
74
+ selectFrame('f3', 'Did this render correctly? (3/3)'),
75
+ ];
76
+ const groups = groupAskFrames(questions, frames);
77
+ expect(groups.map((group) => group.map((frame) => frame.id))).toEqual([['f1'], ['f2'], ['f3']]);
78
+ });
79
+
80
+ test('keeps the free-text follow-up dialog on the question that asked for it', () => {
81
+ const frames: ExtensionUiDialogRequest[] = [
82
+ selectFrame('f1', 'Which language should we use for this test? (1/3)'),
83
+ { type: 'extension_ui_request', id: 'f1b', method: 'editor', title: 'Which language should we use for this test?' },
84
+ selectFrame('f2', 'What answer style do you prefer for testing? (2/3)'),
85
+ ];
86
+ const groups = groupAskFrames(questions, frames);
87
+ expect(groups[0].map((frame) => frame.id)).toEqual(['f1', 'f1b']);
88
+ expect(groups[1].map((frame) => frame.id)).toEqual(['f2']);
89
+ expect(groups[2]).toEqual([]);
90
+ });
91
+
92
+ test('falls back to order when a rebuild decorates the title in a way we cannot parse', () => {
93
+ const frames = [
94
+ selectFrame('f1', 'Some unknown header'),
95
+ selectFrame('f2', 'Another unknown header'),
96
+ selectFrame('f3', 'A third unknown header'),
97
+ ];
98
+ const groups = groupAskFrames(questions, frames);
99
+ expect(groups.map((group) => group.map((frame) => frame.id))).toEqual([['f1'], ['f2'], ['f3']]);
100
+ });
101
+
102
+ test('keeps repeated multi-select dialogs on the question being answered', () => {
103
+ const frames = [
104
+ selectFrame('f1', '(1 selected) Which language should we use for this test? (1/3)'),
105
+ selectFrame('f2', '(1 selected) Which language should we use for this test? (1/3)'),
106
+ selectFrame('f3', 'What answer style do you prefer for testing? (2/3)'),
107
+ ];
108
+ const groups = groupAskFrames(questions, frames);
109
+ expect(groups[0].map((frame) => frame.id)).toEqual(['f1', 'f2']);
110
+ expect(groups[1].map((frame) => frame.id)).toEqual(['f3']);
111
+ });
112
+ });
113
+
114
+ describe('parseAskResult', () => {
115
+ const questions = parseAskQuestions(askTool(THREE_QUESTIONS));
116
+
117
+ test('reads every recorded answer from details.results', () => {
118
+ const answers = parseAskResult(askTool(THREE_QUESTIONS, {
119
+ details: {
120
+ results: [
121
+ { id: 'language', question: 'Which language?', selectedOptions: ['Python'] },
122
+ { id: 'style', question: 'Style?', selectedOptions: [], customInput: 'Dadadada' },
123
+ { id: 'confirm', question: 'Correct?', selectedOptions: ['Yes'], timedOut: true },
124
+ ],
125
+ },
126
+ }), questions);
127
+
128
+ expect(answers[0]).toEqual({ selectedOptions: ['Python'], customInput: undefined, timedOut: false });
129
+ expect(answers[1]?.customInput).toBe('Dadadada');
130
+ expect(answers[2]?.timedOut).toBe(true);
131
+ });
132
+
133
+ test('reads the flat shape omp returns for a single-question ask', () => {
134
+ const single = parseAskQuestions(askTool({ questions: [THREE_QUESTIONS.questions[0]] }));
135
+ const answers = parseAskResult(askTool({}, { details: { selectedOptions: ['Go'], multi: false } }), single);
136
+ expect(answers[0]?.selectedOptions).toEqual(['Go']);
137
+ });
138
+
139
+ test('leaves every question unanswered while the ask is still running', () => {
140
+ expect(parseAskResult(askTool(THREE_QUESTIONS), questions)).toEqual([null, null, null]);
141
+ });
142
+
143
+ test('falls back to the rendered text when details never reached the client', () => {
144
+ const output = 'User answers:\nlanguage: Python\nstyle: Balanced\nconfirm: Yes';
145
+ const answers = parseAskResult(askTool(THREE_QUESTIONS, { output }), questions);
146
+ expect(answers.map((answer) => answer?.selectedOptions)).toEqual([['Python'], ['Balanced'], ['Yes']]);
147
+ });
148
+
149
+ test('reads a typed custom answer out of the rendered text', () => {
150
+ const output = 'User answers:\nlanguage: Python\nstyle: "Dadadada"\nconfirm: Yes';
151
+ const answers = parseAskResult(askTool(THREE_QUESTIONS, { output }), questions);
152
+ expect(answers[1]).toEqual({ selectedOptions: [], customInput: 'Dadadada' });
153
+ });
154
+
155
+ test('reads the single-question result shapes', () => {
156
+ const single = parseAskQuestions(askTool({ questions: [THREE_QUESTIONS.questions[0]] }));
157
+ expect(parseAskResult(askTool({}, { output: 'User selected: Python' }), single)[0]?.selectedOptions).toEqual(['Python']);
158
+ expect(parseAskResult(askTool({}, { output: 'User provided custom input: Dadadada' }), single)[0]?.customInput)
159
+ .toBe('Dadadada');
160
+ });
161
+
162
+ test('never reads a failure message as an answer', () => {
163
+ const failed = askTool(THREE_QUESTIONS, {
164
+ output: 'Ask tool was cancelled by the user',
165
+ status: 'error' as const,
166
+ });
167
+ expect(parseAskResult(failed, questions)).toEqual([null, null, null]);
168
+ });
169
+ });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Pure helpers for omp's `ask` tool: read its `questions` payload, pair the
8
+ * per-question dialogs omp raises for them back to the question they belong to,
9
+ * and read the recorded answers out of the tool result.
10
+ *
11
+ * The pairing is load-bearing, and the reason is worth stating: omp's RPC UI
12
+ * context implements `select`/`confirm`/`input`/`editor` but NOT the rich
13
+ * `askDialog` primitive the TUI uses. `AskTool` therefore falls back to its
14
+ * per-question loop, and over RPC a multi-question ask reaches the chamber as
15
+ * ONE blocking `select` frame per question, in order — never as one payload. The
16
+ * frame carries the question as its `title` (plus a ` (2/3)` progress suffix and
17
+ * a `(2 selected) ` prefix for multi-select questions), which is what lets the
18
+ * inline card put each frame back on the question it asked.
19
+ */
20
+
21
+ import type { ToolCallData } from '@/shared/types';
22
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
23
+
24
+ export interface AskOption {
25
+ label: string;
26
+ description?: string;
27
+ preview?: string;
28
+ }
29
+
30
+ export interface AskQuestion {
31
+ id?: string;
32
+ /** Short chip omp shows above the question ("Authentication"). */
33
+ header?: string;
34
+ question: string;
35
+ options: AskOption[];
36
+ multi: boolean;
37
+ /** Index of the option omp marks ` (Recommended)`. */
38
+ recommended?: number;
39
+ }
40
+
41
+ export interface AskAnswer {
42
+ selectedOptions: string[];
43
+ customInput?: string;
44
+ /** omp auto-selected the answer after `ask.timeout` elapsed. */
45
+ timedOut?: boolean;
46
+ }
47
+
48
+ /** The free-text escape hatch omp appends to every question's option list. */
49
+ const OTHER_OPTION = /^other\b/i;
50
+ /** Trailing ` (2/3)` omp adds while looping over several questions. */
51
+ const PROGRESS_SUFFIX = /\s+\(\d+\s*\/\s*\d+\)$/;
52
+ /** Leading `(2 selected) ` omp prepends to a multi-select question's title. */
53
+ const SELECTED_COUNT_PREFIX = /^\(\d+\s+selected\)\s*/;
54
+
55
+ /** True for the literal "Other (type your own)" row, which is a prompt to type
56
+ * an answer rather than an answer itself. */
57
+ export function isOtherOption(label: string): boolean {
58
+ return OTHER_OPTION.test(label.trim());
59
+ }
60
+
61
+ /** Normalized question text, with the decorations omp adds to a live dialog
62
+ * title stripped, so a frame can be matched against the question that raised it. */
63
+ export function normalizeAskText(value: string): string {
64
+ return value
65
+ .replace(SELECTED_COUNT_PREFIX, '')
66
+ .replace(PROGRESS_SUFFIX, '')
67
+ .replace(/\s+/g, ' ')
68
+ .trim()
69
+ .toLowerCase();
70
+ }
71
+
72
+ function readOption(raw: unknown): AskOption | null {
73
+ if (typeof raw === 'string') {
74
+ const label = raw.trim();
75
+ return label ? { label } : null;
76
+ }
77
+ if (!raw || typeof raw !== 'object') return null;
78
+ const record = raw as Record<string, unknown>;
79
+ const label = typeof record.label === 'string' ? record.label.trim() : '';
80
+ if (!label) return null;
81
+ return {
82
+ label,
83
+ description: typeof record.description === 'string' ? record.description : undefined,
84
+ preview: typeof record.preview === 'string' ? record.preview : undefined,
85
+ };
86
+ }
87
+
88
+ /** True for a tool call that is omp's `ask` tool. */
89
+ export function isAskToolCall(tool: ToolCallData): boolean {
90
+ const name = typeof tool.name === 'string' && tool.name ? tool.name : tool.type;
91
+ return name === 'ask' || tool.title === 'ask';
92
+ }
93
+
94
+ /**
95
+ * The `questions` array from an `ask` tool call's arguments. Empty when the tool
96
+ * is not an ask call or its arguments were truncated — callers fall back to the
97
+ * raw arguments/output rendering in that case.
98
+ */
99
+ export function parseAskQuestions(tool: ToolCallData): AskQuestion[] {
100
+ const input = tool.input;
101
+ const record = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : null;
102
+ const raw = record && Array.isArray(record.questions) ? record.questions : [];
103
+ const questions: AskQuestion[] = [];
104
+
105
+ for (const entry of raw) {
106
+ if (!entry || typeof entry !== 'object') continue;
107
+ const question = entry as Record<string, unknown>;
108
+ const text = typeof question.question === 'string' ? question.question.trim() : '';
109
+ if (!text) continue;
110
+ questions.push({
111
+ id: typeof question.id === 'string' ? question.id : undefined,
112
+ header: typeof question.header === 'string' && question.header.trim() ? question.header.trim() : undefined,
113
+ question: text,
114
+ options: Array.isArray(question.options)
115
+ ? question.options.map(readOption).filter((option): option is AskOption => option !== null)
116
+ : [],
117
+ multi: question.multi === true,
118
+ recommended: typeof question.recommended === 'number' ? question.recommended : undefined,
119
+ });
120
+ }
121
+
122
+ return questions;
123
+ }
124
+
125
+ function readAnswer(row: Record<string, unknown>): AskAnswer {
126
+ return {
127
+ selectedOptions: Array.isArray(row.selectedOptions)
128
+ ? row.selectedOptions.filter((value): value is string => typeof value === 'string')
129
+ : [],
130
+ customInput: typeof row.customInput === 'string' && row.customInput ? row.customInput : undefined,
131
+ timedOut: row.timedOut === true,
132
+ };
133
+ }
134
+
135
+ /** One `<id>: <answer>` line of a multi-question result. */
136
+ const ANSWER_LINE = /^([\w.-]+):\s*(.+)$/;
137
+ /** The single-question result's two prose shapes. */
138
+ const SELECTED_LINE = /^User selected:\s*(.+)$/m;
139
+ const CUSTOM_LINE = /^User provided custom input:\s*([\s\S]+)$/m;
140
+
141
+ /** `value` as omp renders it: a quoted string is typed input, a bare one is a
142
+ * picked label (comma-joined when the question allows several). */
143
+ function toAnswer(value: string, multi: boolean): AskAnswer {
144
+ const text = value.trim();
145
+ if (/^"[\s\S]*"$/.test(text)) return { selectedOptions: [], customInput: text.slice(1, -1) };
146
+ const labels = multi && text.includes(', ') ? text.split(', ') : [text];
147
+ return { selectedOptions: labels.map((label) => label.trim()).filter(Boolean) };
148
+ }
149
+
150
+ /**
151
+ * Answers recovered from the tool's rendered text, used when `details` never
152
+ * reached this client. `details` is the richer source, but it is dropped by the
153
+ * live `tool_execution_end` frame on some omp builds; the text is the one part
154
+ * of a settled result every path carries, and it names each answer by question
155
+ * id (`language: Python`) or as a single sentence for a one-question ask.
156
+ */
157
+ function parseAskOutputAnswers(output: string, questions: AskQuestion[]): (AskAnswer | null)[] {
158
+ const answers = questions.map((): AskAnswer | null => null);
159
+ const text = output.trim();
160
+ if (!text) return answers;
161
+
162
+ const byId = new Map<string, AskAnswer>();
163
+ for (const line of text.split('\n')) {
164
+ const match = ANSWER_LINE.exec(line.trim());
165
+ if (match) byId.set(match[1], toAnswer(match[2], questions.some((question) => question.multi)));
166
+ }
167
+ if (byId.size > 0) {
168
+ const ordered = [...byId.values()];
169
+ for (let index = 0; index < questions.length; index += 1) {
170
+ // The question id is the key omp prints; position is what is left when a
171
+ // payload carries no ids at all (omp requires them, so this is defensive).
172
+ const id = questions[index].id;
173
+ answers[index] = (id ? byId.get(id) : undefined) ?? ordered[index] ?? null;
174
+ }
175
+ return answers;
176
+ }
177
+
178
+ const selected = SELECTED_LINE.exec(text);
179
+ if (selected) {
180
+ answers[0] = toAnswer(selected[1], questions[0].multi);
181
+ return answers;
182
+ }
183
+ const custom = CUSTOM_LINE.exec(text);
184
+ if (custom) {
185
+ answers[0] = { selectedOptions: [], customInput: custom[1].trim() };
186
+ return answers;
187
+ }
188
+ if (questions.length === 1 && !text.includes('\n')) {
189
+ answers[0] = { selectedOptions: [text] };
190
+ }
191
+ return answers;
192
+ }
193
+
194
+ /**
195
+ * Answers recorded in an `ask` tool result, positionally aligned with
196
+ * `questions`. omp returns `details.results[]` for a multi-question ask and the
197
+ * bare answer fields for a single-question one; the rendered text backs both up.
198
+ */
199
+ export function parseAskResult(tool: ToolCallData, questions: AskQuestion[]): (AskAnswer | null)[] {
200
+ const answers = questions.map((): AskAnswer | null => null);
201
+ if (questions.length === 0) return answers;
202
+
203
+ const details = tool.details ?? {};
204
+ const results = Array.isArray(details.results) ? (details.results as Record<string, unknown>[]) : null;
205
+ if (results) {
206
+ for (let index = 0; index < answers.length; index += 1) {
207
+ const row = results[index];
208
+ if (row && typeof row === 'object') answers[index] = readAnswer(row);
209
+ }
210
+ return answers;
211
+ }
212
+
213
+ if (typeof details.selectedOptions !== 'undefined' || typeof details.customInput === 'string') {
214
+ answers[0] = readAnswer(details);
215
+ return answers;
216
+ }
217
+
218
+ // A failed/cancelled ask answers nothing; its text is the reason, which would
219
+ // otherwise read as an answer for whichever id its first sentence names.
220
+ if (tool.status === 'error' || tool.status === 'aborted' || tool.isError) return answers;
221
+
222
+ return parseAskOutputAnswers(typeof tool.output === 'string' ? tool.output : '', questions);
223
+ }
224
+
225
+ /**
226
+ * Bucket the ask's dialogs by question index.
227
+ *
228
+ * Assignment follows omp's own order rather than pure title matching: a frame
229
+ * whose title names a question moves the cursor onto it, and any other frame
230
+ * (the `editor`/`input` dialog omp opens after "Other (type your own)") belongs
231
+ * to whichever question was last named. A title that names an EARLIER question
232
+ * is ignored — omp walks the questions forward and never re-asks one, so a
233
+ * repeat can only be a multi-select re-render of the question already in hand.
234
+ */
235
+ export function groupAskFrames(
236
+ questions: AskQuestion[],
237
+ frames: ExtensionUiDialogRequest[],
238
+ ): ExtensionUiDialogRequest[][] {
239
+ const groups = questions.map((): ExtensionUiDialogRequest[] => []);
240
+ if (questions.length === 0) return groups;
241
+ const titles = questions.map((question) => normalizeAskText(question.question));
242
+
243
+ let cursor = 0;
244
+ for (const frame of frames) {
245
+ const title = typeof frame.title === 'string' ? normalizeAskText(frame.title) : '';
246
+ const index = titles.indexOf(title);
247
+ if (index > cursor) {
248
+ cursor = index;
249
+ } else if (
250
+ index === -1 &&
251
+ frame.method === 'select' &&
252
+ groups[cursor].some((held) => held.method === 'select') &&
253
+ cursor + 1 < groups.length
254
+ ) {
255
+ // Title matching is the authority, but a question is asked by exactly one
256
+ // `select` — a second one that matches nothing is the NEXT question with
257
+ // decorations this build does not know about, not a repeat.
258
+ cursor += 1;
259
+ }
260
+ groups[cursor].push(frame);
261
+ }
262
+
263
+ return groups;
264
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ codeWindow,
10
+ clampWindow,
11
+ firstWindow,
12
+ measuredGeometry,
13
+ resolveWindow,
14
+ shouldWindow,
15
+ uniformGeometry,
16
+ windowSpace,
17
+ WHOLE_DOCUMENT_MAX_CHARS,
18
+ WHOLE_DOCUMENT_MAX_LINES,
19
+ WINDOW_OVERSCAN,
20
+ type LineGeometry,
21
+ } from '@/shared/lib/code/lazy-window';
22
+
23
+ const ROW = 18;
24
+
25
+ /** `measuredGeometry` returns null for heights that do not fit the document. */
26
+ function measured(heights: number[]): LineGeometry {
27
+ const geometry = measuredGeometry(heights, ROW, heights.length);
28
+ if (!geometry) throw new Error('heights should describe every line');
29
+ return geometry;
30
+ }
31
+
32
+ describe('shouldWindow', () => {
33
+ test('leaves a short document on the plain surface', () => {
34
+ expect(shouldWindow(120, 4_000)).toBe(false);
35
+ });
36
+
37
+ test('windows a document that is long in lines or in characters', () => {
38
+ expect(shouldWindow(WHOLE_DOCUMENT_MAX_LINES + 1, 10)).toBe(true);
39
+ expect(shouldWindow(10, WHOLE_DOCUMENT_MAX_CHARS + 1)).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe('uniformGeometry', () => {
44
+ const geometry = uniformGeometry(ROW, 500);
45
+
46
+ test('spaces lines one row apart and totals the document', () => {
47
+ expect(geometry.offsetOf(0)).toBe(0);
48
+ expect(geometry.offsetOf(10)).toBe(10 * ROW);
49
+ expect(geometry.totalHeight).toBe(500 * ROW);
50
+ });
51
+
52
+ test('maps an offset back to the line covering it', () => {
53
+ expect(geometry.lineAt(0)).toBe(0);
54
+ expect(geometry.lineAt(ROW - 1)).toBe(0);
55
+ expect(geometry.lineAt(ROW)).toBe(1);
56
+ expect(geometry.lineAt(499 * ROW + 3)).toBe(499);
57
+ });
58
+
59
+ test('clamps an offset outside the document', () => {
60
+ expect(geometry.lineAt(-50)).toBe(0);
61
+ expect(geometry.lineAt(999_999)).toBe(499);
62
+ expect(geometry.offsetOf(500)).toBe(geometry.totalHeight);
63
+ expect(geometry.offsetOf(999)).toBe(geometry.totalHeight);
64
+ });
65
+ });
66
+
67
+ describe('measuredGeometry', () => {
68
+ // Every third line wraps over three rows; the rest are single rows.
69
+ const heights = Array.from({ length: 9 }, (_, index) => (index % 3 === 2 ? ROW * 3 : ROW));
70
+ const geometry = measured(heights);
71
+
72
+ test('builds offsets from the wrapped heights', () => {
73
+ expect(geometry.offsetOf(0)).toBe(0);
74
+ expect(geometry.offsetOf(1)).toBe(ROW);
75
+ expect(geometry.offsetOf(2)).toBe(2 * ROW);
76
+ // Line 2 spans three rows, so line 3 starts after all three.
77
+ expect(geometry.offsetOf(3)).toBe(5 * ROW);
78
+ expect(geometry.totalHeight).toBe(heights.reduce((sum, height) => sum + height, 0));
79
+ });
80
+
81
+ test('maps an offset inside a wrapped line to that line', () => {
82
+ expect(geometry.lineAt(2 * ROW)).toBe(2);
83
+ expect(geometry.lineAt(4 * ROW + 5)).toBe(2);
84
+ expect(geometry.lineAt(5 * ROW)).toBe(3);
85
+ });
86
+
87
+ test('refuses heights that do not describe every line', () => {
88
+ expect(measuredGeometry([ROW, ROW], ROW, 5)).toBeNull();
89
+ expect(measuredGeometry([], ROW, 0)).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('resolveWindow', () => {
94
+ const geometry = uniformGeometry(ROW, 1_000);
95
+
96
+ test('covers the viewport plus overscan on both sides', () => {
97
+ // Viewport rows 100..128 (the last row is half covered, so it is included).
98
+ const window = resolveWindow(geometry, 100 * ROW, 28 * ROW);
99
+ expect(window).toEqual({ start: 100 - WINDOW_OVERSCAN, end: 129 + WINDOW_OVERSCAN });
100
+ });
101
+
102
+ test('never runs past either end of the document', () => {
103
+ expect(resolveWindow(geometry, 0, ROW)).toEqual({ start: 0, end: 2 + WINDOW_OVERSCAN });
104
+ expect(resolveWindow(geometry, 999 * ROW, 40 * ROW).end).toBe(1_000);
105
+ });
106
+
107
+ test('keeps a zero-height band to at least the line under it', () => {
108
+ const window = resolveWindow(geometry, 400 * ROW, 0);
109
+ expect(window.start).toBeLessThanOrEqual(400);
110
+ expect(window.end).toBeGreaterThan(400);
111
+ });
112
+ });
113
+
114
+ describe('firstWindow', () => {
115
+ test('bounds the opening render of a long document', () => {
116
+ expect(firstWindow(20_000).start).toBe(0);
117
+ expect(firstWindow(20_000).end).toBeLessThan(20_000);
118
+ });
119
+
120
+ test('keeps a short document whole', () => {
121
+ expect(firstWindow(3)).toEqual({ start: 0, end: 3 });
122
+ });
123
+ });
124
+
125
+ describe('clampWindow', () => {
126
+ test('pulls a window left over from another document back inside it', () => {
127
+ expect(clampWindow({ start: 9_000, end: 9_500 }, 40)).toEqual({ start: 39, end: 40 });
128
+ expect(clampWindow({ start: -5, end: 10 }, 40)).toEqual({ start: 0, end: 10 });
129
+ });
130
+
131
+ test('never returns an empty range', () => {
132
+ expect(clampWindow({ start: 5, end: 5 }, 40).end).toBeGreaterThan(5);
133
+ });
134
+ });
135
+
136
+ describe('windowSpace', () => {
137
+ test('reserves exactly the height of the lines outside the window', () => {
138
+ const geometry = uniformGeometry(ROW, 100);
139
+ const space = windowSpace(geometry, { start: 10, end: 30 });
140
+ expect(space).toEqual({ top: 10 * ROW, bottom: 70 * ROW, breakAtEnd: false });
141
+ expect(space.top + space.bottom + (30 - 10) * ROW).toBe(geometry.totalHeight);
142
+ });
143
+
144
+ test('flags a window that reaches the last line', () => {
145
+ const geometry = uniformGeometry(ROW, 100);
146
+ expect(windowSpace(geometry, { start: 90, end: 100 }).breakAtEnd).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe('codeWindow', () => {
151
+ test('clamps the range and spaces it in one step', () => {
152
+ const geometry = uniformGeometry(ROW, 50);
153
+ expect(codeWindow(geometry, { start: 0, end: 5_000 })).toEqual({
154
+ start: 0,
155
+ end: 50,
156
+ top: 0,
157
+ bottom: 0,
158
+ breakAtEnd: true,
159
+ });
160
+ });
161
+ });