yaver-feedback-react-native 0.9.0 → 0.9.3

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 (144) hide show
  1. package/README.md +102 -1
  2. package/app.plugin.js +126 -9
  3. package/dist/AuthOverlay.d.ts +1 -14
  4. package/dist/AuthOverlay.js +9 -62
  5. package/dist/BlackBox.js +9 -1
  6. package/dist/DeployPanel.js +65 -0
  7. package/dist/Discovery.js +13 -8
  8. package/dist/DogfoodRuntime.d.ts +124 -0
  9. package/dist/DogfoodRuntime.js +273 -0
  10. package/dist/FeedbackModal.js +505 -65
  11. package/dist/LoginScreen.d.ts +1 -5
  12. package/dist/LoginScreen.js +2 -6
  13. package/dist/MachinePickerScreen.d.ts +1 -3
  14. package/dist/MachinePickerScreen.js +6 -18
  15. package/dist/P2PClient.d.ts +178 -2
  16. package/dist/P2PClient.js +493 -3
  17. package/dist/P2PDogfoodDriver.d.ts +12 -0
  18. package/dist/P2PDogfoodDriver.js +118 -0
  19. package/dist/PairDeviceModal.d.ts +2 -3
  20. package/dist/ShakeDetector.js +2 -0
  21. package/dist/VibeChatScreen.d.ts +11 -1
  22. package/dist/VibeChatScreen.js +200 -41
  23. package/dist/YaverFeedback.d.ts +132 -0
  24. package/dist/YaverFeedback.js +633 -65
  25. package/dist/YaverModeBadge.d.ts +22 -0
  26. package/dist/YaverModeBadge.js +219 -0
  27. package/dist/__tests__/AuthDevices.test.js +1 -48
  28. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  29. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  30. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  31. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  32. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  33. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  34. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  35. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  36. package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
  37. package/dist/__tests__/DogfoodRuntime.test.js +116 -0
  38. package/dist/__tests__/P2PClient.test.js +2 -2
  39. package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
  40. package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
  41. package/dist/__tests__/ReportIdentity.test.d.ts +25 -0
  42. package/dist/__tests__/ReportIdentity.test.js +180 -0
  43. package/dist/__tests__/SDKToken.test.js +1 -1
  44. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  45. package/dist/__tests__/ShakeToggle.test.js +121 -0
  46. package/dist/__tests__/YaverFeedback.test.js +13 -3
  47. package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
  48. package/dist/__tests__/deviceDogfood.test.js +86 -0
  49. package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
  50. package/dist/__tests__/dogfoodPolicy.test.js +33 -0
  51. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  52. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  53. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  54. package/dist/__tests__/reloadActions.test.js +129 -0
  55. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  56. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  57. package/dist/__tests__/types.test.js +5 -5
  58. package/dist/_core/ansi.d.ts +117 -0
  59. package/dist/_core/ansi.js +468 -0
  60. package/dist/_core/ansi.test.d.ts +1 -0
  61. package/dist/_core/ansi.test.js +225 -0
  62. package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
  63. package/dist/_core/buildFeedbackPrompt.js +18 -72
  64. package/dist/_core/constants.d.ts +19 -6
  65. package/dist/_core/constants.js +20 -7
  66. package/dist/_core/device.d.ts +1 -5
  67. package/dist/_core/device.js +5 -14
  68. package/dist/_core/endpoints.d.ts +0 -7
  69. package/dist/_core/endpoints.js +0 -7
  70. package/dist/_core/index.d.ts +4 -0
  71. package/dist/_core/index.js +4 -0
  72. package/dist/_core/remoteless.d.ts +44 -0
  73. package/dist/_core/remoteless.js +75 -0
  74. package/dist/_core/trace.d.ts +47 -0
  75. package/dist/_core/trace.js +38 -0
  76. package/dist/_core/trace.test.d.ts +1 -0
  77. package/dist/_core/trace.test.js +60 -0
  78. package/dist/auth.d.ts +3 -60
  79. package/dist/auth.js +6 -88
  80. package/dist/deviceDogfood.d.ts +53 -0
  81. package/dist/deviceDogfood.js +137 -0
  82. package/dist/dogfoodPolicy.d.ts +25 -0
  83. package/dist/dogfoodPolicy.js +24 -0
  84. package/dist/index.d.ts +17 -4
  85. package/dist/index.js +33 -7
  86. package/dist/reloadActions.d.ts +88 -0
  87. package/dist/reloadActions.js +200 -0
  88. package/dist/storeShots.d.ts +67 -0
  89. package/dist/storeShots.js +137 -0
  90. package/dist/types.d.ts +208 -8
  91. package/package.json +13 -4
  92. package/src/AuthOverlay.tsx +20 -106
  93. package/src/BlackBox.ts +10 -1
  94. package/src/DeployPanel.tsx +74 -0
  95. package/src/Discovery.ts +13 -8
  96. package/src/DogfoodRuntime.ts +373 -0
  97. package/src/FeedbackModal.tsx +619 -79
  98. package/src/LoginScreen.tsx +2 -22
  99. package/src/MachinePickerScreen.tsx +6 -21
  100. package/src/P2PClient.ts +580 -4
  101. package/src/P2PDogfoodDriver.ts +132 -0
  102. package/src/PairDeviceModal.tsx +2 -3
  103. package/src/ShakeDetector.ts +1 -0
  104. package/src/VibeChatScreen.tsx +232 -42
  105. package/src/YaverFeedback.ts +631 -68
  106. package/src/YaverModeBadge.tsx +234 -0
  107. package/src/__tests__/AuthDevices.test.ts +1 -52
  108. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  109. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  110. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  111. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  112. package/src/__tests__/DogfoodRuntime.test.ts +135 -0
  113. package/src/__tests__/P2PClient.test.ts +2 -2
  114. package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
  115. package/src/__tests__/ReportIdentity.test.ts +212 -0
  116. package/src/__tests__/SDKToken.test.ts +1 -1
  117. package/src/__tests__/ShakeToggle.test.ts +153 -0
  118. package/src/__tests__/YaverFeedback.test.ts +15 -3
  119. package/src/__tests__/deviceDogfood.test.ts +77 -0
  120. package/src/__tests__/dogfoodPolicy.test.ts +34 -0
  121. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  122. package/src/__tests__/reloadActions.test.ts +171 -0
  123. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  124. package/src/__tests__/types.test.ts +5 -5
  125. package/src/_core/ansi.test.ts +250 -0
  126. package/src/_core/ansi.ts +475 -0
  127. package/src/_core/buildFeedbackPrompt.ts +18 -95
  128. package/src/_core/constants.ts +20 -6
  129. package/src/_core/device.ts +5 -16
  130. package/src/_core/endpoints.ts +0 -7
  131. package/src/_core/index.ts +4 -0
  132. package/src/_core/remoteless.ts +110 -0
  133. package/src/_core/trace.test.ts +58 -0
  134. package/src/_core/trace.ts +75 -0
  135. package/src/auth.ts +7 -156
  136. package/src/deviceDogfood.ts +171 -0
  137. package/src/dogfoodPolicy.ts +40 -0
  138. package/src/index.ts +61 -11
  139. package/src/reloadActions.ts +273 -0
  140. package/src/storeShots.ts +189 -0
  141. package/src/types.ts +209 -8
  142. package/dist/GuestOnboardingScreen.d.ts +0 -8
  143. package/dist/GuestOnboardingScreen.js +0 -282
  144. package/src/GuestOnboardingScreen.tsx +0 -307
@@ -0,0 +1,171 @@
1
+ import {
2
+ describeReloadFailure,
3
+ reloadActions,
4
+ reloadFrameworkFamily,
5
+ reloadRequest,
6
+ RELOAD_APP_PATH,
7
+ RELOAD_PATH,
8
+ } from '../reloadActions';
9
+
10
+ const DEV = { isDevBuild: true, connected: true };
11
+
12
+ describe('reloadActions — the production guard', () => {
13
+ // THIS IS THE GUARD. Prove it by breaking it: flip `if (!opts.isDevBuild)`
14
+ // in reloadActions.ts to `if (opts.isDevBuild)` and this test fails while
15
+ // every other test in this file still passes.
16
+ it('returns NOTHING in a production build, even with a healthy dev server', () => {
17
+ const actions = reloadActions(
18
+ { running: true, framework: 'vite' },
19
+ { isDevBuild: false, connected: true, includeRebuild: true },
20
+ );
21
+ expect(actions).toEqual([]);
22
+ });
23
+
24
+ it('returns actions in a dev build', () => {
25
+ const actions = reloadActions({ running: true, framework: 'vite' }, DEV);
26
+ expect(actions.map((a) => a.id)).toEqual(['hot', 'full']);
27
+ expect(actions.every((a) => a.enabled)).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('reloadFrameworkFamily', () => {
32
+ it.each([
33
+ ['flutter', 'flutter'],
34
+ ['expo', 'react-native'],
35
+ ['react-native', 'react-native'],
36
+ ['vite', 'web'],
37
+ ['nextjs', 'web'],
38
+ ['', 'unknown'],
39
+ ['godot', 'unknown'],
40
+ ])('maps %s → %s', (framework, family) => {
41
+ expect(reloadFrameworkFamily(framework)).toBe(family);
42
+ });
43
+ });
44
+
45
+ describe('per-stack labels', () => {
46
+ it('calls the full action a Hot Restart on Flutter (stdin R), not a Full Reload', () => {
47
+ const actions = reloadActions({ running: true, framework: 'flutter' }, DEV);
48
+ expect(actions.find((a) => a.id === 'hot')!.label).toBe('Hot Reload');
49
+ expect(actions.find((a) => a.id === 'full')!.label).toBe('Hot Restart');
50
+ expect(actions.find((a) => a.id === 'full')!.hint).toContain('(R)');
51
+ });
52
+
53
+ it('calls it a Full Reload everywhere else', () => {
54
+ for (const framework of ['expo', 'vite', 'nextjs']) {
55
+ const actions = reloadActions({ running: true, framework }, DEV);
56
+ expect(actions.find((a) => a.id === 'full')!.label).toBe('Full Reload');
57
+ }
58
+ });
59
+ });
60
+
61
+ describe('URL / payload construction', () => {
62
+ it('sends fast for hot and full for full, both to /dev/reload', () => {
63
+ const actions = reloadActions({ running: true, framework: 'flutter' }, DEV);
64
+ expect(reloadRequest(actions[0])).toEqual({
65
+ method: 'POST',
66
+ path: RELOAD_PATH,
67
+ body: { mode: 'fast' },
68
+ });
69
+ expect(reloadRequest(actions[1])).toEqual({
70
+ method: 'POST',
71
+ path: RELOAD_PATH,
72
+ body: { mode: 'full' },
73
+ });
74
+ });
75
+
76
+ it('routes the RN bundle rebuild to /dev/reload-app', () => {
77
+ const actions = reloadActions(
78
+ { running: false },
79
+ { ...DEV, includeRebuild: true },
80
+ );
81
+ const rebuild = actions.find((a) => a.id === 'rebuild')!;
82
+ expect(reloadRequest(rebuild)).toEqual({
83
+ method: 'POST',
84
+ path: RELOAD_APP_PATH,
85
+ body: { mode: 'bundle' },
86
+ });
87
+ });
88
+ });
89
+
90
+ describe('a blocked action NAMES the blocker', () => {
91
+ it('names the missing dev server and the command that starts it', () => {
92
+ const actions = reloadActions({ running: false }, { ...DEV, machineLabel: 'primary' });
93
+ expect(actions).toHaveLength(2);
94
+ for (const action of actions) {
95
+ expect(action.enabled).toBe(false);
96
+ expect(action.disabledReason).toContain('primary');
97
+ expect(action.disabledReason).toContain('yaver dev start');
98
+ }
99
+ });
100
+
101
+ it('names "still building" rather than pretending nothing is running', () => {
102
+ const actions = reloadActions({ running: true, building: true, framework: 'expo' }, DEV);
103
+ expect(actions[0].disabledReason).toContain('still building');
104
+ });
105
+
106
+ it('names the missing machine when disconnected', () => {
107
+ const actions = reloadActions(
108
+ { running: true, framework: 'expo' },
109
+ { isDevBuild: true, connected: false },
110
+ );
111
+ expect(actions[0].disabledReason).toContain('Not connected');
112
+ });
113
+
114
+ it('keeps Rebuild Bundle ENABLED with no dev server — that is its whole point', () => {
115
+ const actions = reloadActions({ running: false }, { ...DEV, includeRebuild: true });
116
+ const rebuild = actions.find((a) => a.id === 'rebuild')!;
117
+ expect(rebuild.enabled).toBe(true);
118
+ expect(rebuild.disabledReason).toBeUndefined();
119
+ });
120
+
121
+ it('disables Rebuild Bundle when there is no machine', () => {
122
+ const actions = reloadActions(
123
+ { running: true },
124
+ { isDevBuild: true, connected: false, includeRebuild: true },
125
+ );
126
+ expect(actions.find((a) => a.id === 'rebuild')!.enabled).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe('describeReloadFailure names a cause, never "failed"', () => {
131
+ it('503 → no dev server', () => {
132
+ expect(describeReloadFailure(503, 'dev server not available')).toContain(
133
+ 'No dev server is running',
134
+ );
135
+ });
136
+
137
+ it('framework cannot hot reload → says so and points at the alternative', () => {
138
+ const msg = describeReloadFailure(500, 'unity does not support hot reload', {
139
+ running: true,
140
+ framework: 'unity',
141
+ });
142
+ expect(msg).toContain('unity');
143
+ expect(msg).toContain('Rebuild Bundle');
144
+ });
145
+
146
+ it('loopback connection refused → the dev server is not listening', () => {
147
+ const msg = describeReloadFailure(
148
+ 502,
149
+ 'Get "http://127.0.0.1:8081/reload": dial tcp 127.0.0.1:8081: connect: connection refused',
150
+ );
151
+ expect(msg).toContain('not listening');
152
+ expect(msg).toContain('yaver dev start');
153
+ });
154
+
155
+ it('401/403 → session, not server', () => {
156
+ expect(describeReloadFailure(401, '')).toContain('sign in again');
157
+ expect(describeReloadFailure(403, '')).toContain('sign in again');
158
+ });
159
+
160
+ it('404 → the agent is too old, and says how to update it', () => {
161
+ expect(describeReloadFailure(404, 'not found')).toContain('yaver-cli@latest');
162
+ });
163
+
164
+ it('5xx → points at the agent log', () => {
165
+ expect(describeReloadFailure(500, 'boom')).toContain('yaver logs');
166
+ });
167
+
168
+ it('status 0 (transport never answered) → machine reachability', () => {
169
+ expect(describeReloadFailure(0, '')).toContain('yaver serve');
170
+ });
171
+ });
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * `reloadActions.ts` is duplicated into yaver-feedback-react-native and
6
+ * yaver-feedback-web on purpose — they are two independently published npm
7
+ * packages, and neither may depend on the other.
8
+ *
9
+ * Duplication without a guard is DRIFT, and drift in this file is the worst
10
+ * kind: it is a *policy* file. If the web copy stops returning [] for a
11
+ * production build and the RN copy still does, the two SDKs disagree about
12
+ * whether a shipped app may show a reload button — and nothing in `tsc`
13
+ * notices, because they are separate compilation units.
14
+ *
15
+ * This is the same shape as beaconParity.test.ts in mobile/: read both
16
+ * sources, assert they agree. Prove it by breaking it — change one word in
17
+ * either copy and this test fails.
18
+ */
19
+ const RN_COPY = join(__dirname, '..', 'reloadActions.ts');
20
+ const WEB_COPY = join(
21
+ __dirname,
22
+ '..', '..', '..', // sdk/feedback/react-native
23
+ 'web', 'src', 'reloadActions.ts',
24
+ );
25
+
26
+ describe('reloadActions parity between the RN and web SDKs', () => {
27
+ it('both copies exist', () => {
28
+ expect(() => readFileSync(RN_COPY, 'utf8')).not.toThrow();
29
+ expect(() => readFileSync(WEB_COPY, 'utf8')).not.toThrow();
30
+ });
31
+
32
+ it('is byte-identical across the two packages', () => {
33
+ const rn = readFileSync(RN_COPY, 'utf8');
34
+ const web = readFileSync(WEB_COPY, 'utf8');
35
+ expect(rn).toEqual(web);
36
+ });
37
+
38
+ it('still carries the production guard in BOTH copies', () => {
39
+ // Named separately from the byte comparison so that if someone
40
+ // legitimately reformats both files the failure still points at the one
41
+ // line that actually matters.
42
+ for (const [name, path] of [['react-native', RN_COPY], ['web', WEB_COPY]] as const) {
43
+ const source = readFileSync(path, 'utf8');
44
+ expect(`${name}: ${source.includes('if (!opts.isDevBuild) return [];')}`).toEqual(
45
+ `${name}: true`,
46
+ );
47
+ }
48
+ });
49
+ });
@@ -51,7 +51,7 @@ describe('React Native SDK types', () => {
51
51
  const bundle: FeedbackBundle = {
52
52
  metadata: {
53
53
  timestamp: '2026-03-24T12:00:00Z',
54
- device: {
54
+ deviceInfo: {
55
55
  platform: 'ios',
56
56
  osVersion: '18.0',
57
57
  model: 'iPhone 16 Pro',
@@ -68,7 +68,7 @@ describe('React Native SDK types', () => {
68
68
  };
69
69
 
70
70
  expect(bundle.metadata.timestamp).toBe('2026-03-24T12:00:00Z');
71
- expect(bundle.metadata.device.platform).toBe('ios');
71
+ expect(bundle.metadata.deviceInfo.platform).toBe('ios');
72
72
  expect(bundle.screenshots).toEqual([]);
73
73
  expect(bundle.video).toBeUndefined();
74
74
  });
@@ -77,7 +77,7 @@ describe('React Native SDK types', () => {
77
77
  const bundle: FeedbackBundle = {
78
78
  metadata: {
79
79
  timestamp: '2026-03-24T12:00:00Z',
80
- device: {
80
+ deviceInfo: {
81
81
  platform: 'android',
82
82
  osVersion: '15',
83
83
  model: 'Pixel 9',
@@ -168,7 +168,7 @@ describe('React Native SDK types', () => {
168
168
  bundle: {
169
169
  metadata: {
170
170
  timestamp: 'now',
171
- device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
171
+ deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
172
172
  app: {},
173
173
  },
174
174
  screenshots: [],
@@ -187,7 +187,7 @@ describe('React Native SDK types', () => {
187
187
  bundle: {
188
188
  metadata: {
189
189
  timestamp: 'now',
190
- device: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
190
+ deviceInfo: { platform: 'ios', osVersion: '18', model: 'iPhone', screenWidth: 393, screenHeight: 852 },
191
191
  app: {},
192
192
  },
193
193
  screenshots: [],
@@ -0,0 +1,250 @@
1
+ // AUTO-SYNCED from shared/client-core/src/ansi.test.ts.
2
+ // DO NOT EDIT IN PLACE. Edit the source and re-run
3
+ // scripts/sync-client-core.sh. CI checks drift via `--check`.
4
+
5
+ /**
6
+ * ansi.test.ts — guards for the shared ANSI tokenizer.
7
+ *
8
+ * The bug this exists for (2026-08-09): the dashboard and mobile app both
9
+ * flattened opencode's raw ANSI stream to plain text with stripAnsi, losing
10
+ * every colour the console has, while the xterm Terminal view kept the
11
+ * bytes — two surfaces, two looks, one product. The tokenizer below is the
12
+ * single classifier both chat renderers consume; these tests pin the tokens
13
+ * so a renderer drift (web spans vs mobile nested Text) can never silently
14
+ * change what a user sees.
15
+ *
16
+ * Run: npx tsx shared/client-core/src/ansi.test.ts
17
+ */
18
+ import {
19
+ ANSI_16_RGB,
20
+ tokenizeAnsi,
21
+ ansiToPlain,
22
+ tokenStreamToLines,
23
+ paletteRgb,
24
+ classifyAnsiLine,
25
+ styleAnsiLines,
26
+ summarizeRawConsole,
27
+ } from "./ansi";
28
+
29
+ let failures = 0;
30
+ const eq = (got: unknown, want: unknown, label: string) => {
31
+ if (JSON.stringify(got) === JSON.stringify(want)) console.log(`ok ${label}`);
32
+ else { console.error(`FAIL ${label}:\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`); failures++; }
33
+ };
34
+ const ok = (c: unknown, label: string) => eq(Boolean(c), true, label);
35
+
36
+ // ── plain text passes through untouched ─────────────────────────────────
37
+ {
38
+ const t = tokenizeAnsi("hello world");
39
+ eq(t, [{ text: "hello world" }], "plain text → single unstyled token");
40
+ }
41
+
42
+ // ── SGR colours ──────────────────────────────────────────────────────────
43
+ {
44
+ const t = tokenizeAnsi("\x1b[31mred\x1b[0m plain");
45
+ eq(t, [
46
+ { text: "red", fg: { kind: "named", index: 1 } },
47
+ { text: " plain" },
48
+ ], "red text then reset → two tokens");
49
+ }
50
+ {
51
+ const t = tokenizeAnsi("\x1b[32m+\x1b[0m added");
52
+ eq(t, [
53
+ { text: "+", fg: { kind: "named", index: 2 } },
54
+ { text: " added" },
55
+ ], "green + line (git patch)");
56
+ }
57
+ {
58
+ const t = tokenizeAnsi("\x1b[1m\x1b[33m$ ls\x1b[0m");
59
+ eq(t, [
60
+ { text: "$ ls", fg: { kind: "named", index: 3 }, bold: true },
61
+ ], "bold yellow $ prompt");
62
+ }
63
+
64
+ // ── 256-colour palette ──────────────────────────────────────────────────
65
+ {
66
+ const t = tokenizeAnsi("\x1b[38;5;208morange\x1b[0m");
67
+ eq(t, [
68
+ { text: "orange", fg: { kind: "palette", index: 208 } },
69
+ ], "xterm-256 orange (208)");
70
+ }
71
+ {
72
+ const [r, g, b] = paletteRgb(208);
73
+ ok(r > 200 && g > 80 && g < 160 && b < 60, "palette 208 is orange-ish RGB");
74
+ }
75
+ {
76
+ const [r, g, b] = paletteRgb(2);
77
+ eq([r, g, b], [...ANSI_16_RGB[2]], "palette index <16 maps to the standard 16");
78
+ }
79
+
80
+ // ── truecolor ───────────────────────────────────────────────────────────
81
+ {
82
+ const t = tokenizeAnsi("\x1b[38;2;255;120;50mtrue\x1b[0m");
83
+ eq(t, [
84
+ { text: "true", fg: { kind: "rgb", rgb: [255, 120, 50] } },
85
+ ], "truecolor SGR 38;2;r;g;b");
86
+ }
87
+
88
+ // ── background ──────────────────────────────────────────────────────────
89
+ {
90
+ const t = tokenizeAnsi("\x1b[48;5;236mgray bg\x1b[0m");
91
+ eq(t, [
92
+ { text: "gray bg", bg: { kind: "palette", index: 236 } },
93
+ ], "xterm-256 background (patch gray)");
94
+ }
95
+
96
+ // ── bold / dim / underline / strike ─────────────────────────────────────
97
+ {
98
+ const t = tokenizeAnsi("\x1b[1mbold\x1b[22m \x1b[2mdim\x1b[0m \x1b[4munder\x1b[0m \x1b[9mstrike\x1b[0m");
99
+ eq(t, [
100
+ { text: "bold", bold: true },
101
+ { text: " " },
102
+ { text: "dim", dim: true },
103
+ { text: " " },
104
+ { text: "under", underline: true },
105
+ { text: " " },
106
+ { text: "strike", strike: true },
107
+ ], "bold/dim/underline/strike attributes");
108
+ }
109
+
110
+ // ── bare \x1b[m resets (the opencode banner emits this constantly) ─────
111
+ {
112
+ const t = tokenizeAnsi("\x1b[0m\n> build · deepseek-v4-flash\n\x1b[0m\n");
113
+ eq(t, [
114
+ { text: "\n> build · deepseek-v4-flash\n\n" },
115
+ ], "bare [0m sequences are dropped, text survives");
116
+ }
117
+
118
+ // ── $ prompt lines in a real opencode run ───────────────────────────────
119
+ {
120
+ const raw = "\x1b[0m\n> build · deepseek-v4-flash\n\x1b[0m\n\x1b[0m$ \x1b[0mls -la\n";
121
+ const t = tokenizeAnsi(raw);
122
+ eq(ansiToPlain(raw), "\n> build · deepseek-v4-flash\n\n$ ls -la\n", "plain extraction matches the visible console text");
123
+ const lines = tokenStreamToLines(t);
124
+ ok(lines.length >= 4, "token stream splits into lines at newlines");
125
+ const last = lines[lines.length - 1];
126
+ ok(last.tokens.some((tk) => tk.text.includes("$ ls -la")), "last line carries the $ prompt text");
127
+ }
128
+
129
+ // ── OSC-8 hyperlink extraction ──────────────────────────────────────────
130
+ {
131
+ const t = tokenizeAnsi("\x1b]8;;https://example.com\x07link text\x1b]8;;\x07 rest");
132
+ eq(t[0]?.href, "https://example.com", "OSC-8 link URL carried on the token");
133
+ eq(t[0]?.text, "link text", "OSC-8 link inner text kept");
134
+ eq(t[1]?.text, " rest", "text after link kept unstyled");
135
+ }
136
+
137
+ // ── OSC-8 scheme allowlist (the audit XSS finding, §6.1) ───────────────
138
+ // A prompt-injected \x1b]8;;javascript:…\x07 link used to reach the renderer
139
+ // verbatim and become an <a href> in the dashboard origin. The tokenizer now
140
+ // drops every scheme except http(s):// and mailto:// — the link becomes plain
141
+ // text, never a clickable/executable URL.
142
+ {
143
+ const evil = "\x1b]8;;javascript:alert(1)\x07click\x1b]8;;\x07";
144
+ const t = tokenizeAnsi(evil);
145
+ eq(t[0]?.href, undefined, "javascript: OSC-8 link is not carried as href");
146
+ eq(t[0]?.text, "click", "the inner text survives as plain text");
147
+
148
+ const data = tokenizeAnsi("\x1b]8;;data:text/html,<script>1</script>\x07x\x1b]8;;\x07");
149
+ eq(data[0]?.href, undefined, "data: OSC-8 link is dropped");
150
+
151
+ const proto = tokenizeAnsi("\x1b]8;;//evil.example/path\x07x\x1b]8;;\x07");
152
+ eq(proto[0]?.href, undefined, "protocol-relative OSC-8 link is dropped");
153
+
154
+ const bare = tokenizeAnsi("\x1b]8;;alert(1)\x07x\x1b]8;;\x07");
155
+ eq(bare[0]?.href, undefined, "scheme-less OSC-8 string is dropped");
156
+
157
+ const mail = tokenizeAnsi("\x1b]8;;mailto:a@b.co\x07mail\x1b]8;;\x07");
158
+ eq(mail[0]?.href, "mailto:a@b.co", "mailto: OSC-8 link is allowed");
159
+ }
160
+
161
+ // ── cursor/erase sequences become line breaks (TUI repaint) ─────────────
162
+ {
163
+ const t = tokenizeAnsi("line1\x1b[2Kline2");
164
+ const plain = t.map((x) => x.text).join("");
165
+ ok(plain.includes("\n"), "erase-line (K) leaves a line boundary for a repaint");
166
+ }
167
+
168
+ // ── unknown SGR codes are ignored, text survives ────────────────────────
169
+ {
170
+ const t = tokenizeAnsi("\x1b[999mweird\x1b[0m ok");
171
+ eq(t, [
172
+ { text: "weird ok" },
173
+ ], "unknown SGR code ignored, adjacent runs merge (no visible style change)");
174
+ }
175
+
176
+ if (failures > 0) {
177
+ console.error(`\n${failures} FAILURE(s)`);
178
+ process.exit(1);
179
+ }
180
+
181
+ // ── structural console-line classification ──────────────────────────────
182
+ {
183
+ eq(classifyAnsiLine("> build · deepseek-v4-flash"), "banner", "opencode build banner");
184
+ eq(classifyAnsiLine("> plan · gpt-5.4"), "banner", "opencode plan banner");
185
+ eq(classifyAnsiLine("$ ls -la"), "prompt", "$ prompt");
186
+ eq(classifyAnsiLine("$"), "plain", "lone $ is plain (no command)");
187
+ eq(classifyAnsiLine("diff --git a/x.ts b/x.ts"), "diff-header", "git diff header");
188
+ eq(classifyAnsiLine("+++ b/src/x.ts"), "diff-file", "+++ file line");
189
+ eq(classifyAnsiLine("--- a/src/x.ts"), "diff-file", "--- file line");
190
+ eq(classifyAnsiLine("@@ -1,2 +1,3 @@"), "diff-hunk", "hunk header");
191
+ eq(classifyAnsiLine("+const x = 1;"), "diff-add", "patch add");
192
+ eq(classifyAnsiLine("-const y = 2;"), "diff-del", "patch del");
193
+ eq(classifyAnsiLine(" + indented keeps leading"), "plain", "indented + is not a patch add");
194
+ eq(classifyAnsiLine("⎿ run npx tsc"), "tool-call", "opencode tool tail");
195
+ eq(classifyAnsiLine("✓ 3 files changed"), "tool-call", "checkmark status");
196
+ eq(classifyAnsiLine("normal output line"), "plain", "ordinary line");
197
+ }
198
+ {
199
+ const styled = styleAnsiLines("\x1b[0m\n> build · deepseek-v4-flash\n\x1b[0m\n$ \x1b[0mls\n");
200
+ ok(styled.length >= 4, "styleAnsiLines splits into lines");
201
+ const hints = styled.map((l) => l.hint);
202
+ ok(hints.includes("banner"), "banner line classified through the one-stop helper");
203
+ ok(hints.includes("prompt"), "$ prompt classified through the one-stop helper");
204
+ }
205
+
206
+ // ── summarizeRawConsole (shared noisy-console reducer) ──────────────────
207
+ {
208
+ const raw = [
209
+ "> build · deepseek-v4-flash", // banner — kept
210
+ "$ npm run build", // prompt echo — dropped
211
+ "workdir: /repo", // runner config banner — dropped
212
+ "compiled 42 files", // real output — kept
213
+ "compiled 42 files", // repeat
214
+ "compiled 42 files", // 3rd repeat — collapsed
215
+ "compiled 42 files", // 4th — dropped
216
+ "diff --git a/x.ts b/x.ts", // diff hunk — dropped
217
+ "──────────", // TUI redraw edge — dropped
218
+ "✓ done", // status — kept
219
+ ].join("\n");
220
+ const out = summarizeRawConsole(raw, false);
221
+ ok(out.includes("> build · deepseek-v4-flash"), "banner survives");
222
+ ok(!out.includes("$ npm run build"), "$ echo dropped");
223
+ ok(!out.includes("workdir:"), "config banner dropped");
224
+ ok(out.includes("compiled 42 files"), "real output kept");
225
+ ok(!out.includes("diff --git"), "diff hunk dropped");
226
+ ok(!out.includes("──────────"), "TUI redraw edge dropped");
227
+ ok(out.includes("✓ done"), "status kept");
228
+ ok(out.includes("noisy lines collapsed"), "collapse count reported");
229
+ // Running budget is tighter than finished.
230
+ const big = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n");
231
+ const running = summarizeRawConsole(big, true).split("\n").length;
232
+ const finished = summarizeRawConsole(big, false).split("\n").length;
233
+ ok(running < finished, "running budget tighter than finished budget");
234
+ // KEEP THE TAIL (2026-08-13): the summarizer evicts the OLDEST kept line
235
+ // when the budget is full, so a live console follows the newest output —
236
+ // never drops it. The old head-keeping bug froze the web task console at
237
+ // the START of a long run while the header claimed it was live.
238
+ const tail = summarizeRawConsole(big, true).split("\n");
239
+ ok(tail.some((l) => l.startsWith("line 199")), "newest output survives the budget (tail kept)");
240
+ ok(!tail.some((l) => l.startsWith("line 0")), "oldest output evicted to make room");
241
+ // The tail must still be recognizably the END of the stream, and the
242
+ // collapse marker must not masquerade as a kept line.
243
+ const noMarker = tail.filter((l) => !l.startsWith("… "));
244
+ ok(noMarker[0].startsWith("line 160"), "first kept line is the oldest evicted window start (budget 40 of 200)");
245
+ // ANSI on kept lines survives (so AnsiConsoleText can still paint them).
246
+ const ansiLine = "\x1b[31mred text\x1b[0m";
247
+ ok(summarizeRawConsole(ansiLine, false).includes("\x1b[31m"), "kept lines keep their escapes");
248
+ }
249
+
250
+ console.log("\nall ansi tests pass");