yaver-feedback-react-native 0.9.2 → 0.9.4

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 (108) hide show
  1. package/README.md +102 -1
  2. package/dist/AuthOverlay.d.ts +1 -14
  3. package/dist/AuthOverlay.js +9 -62
  4. package/dist/Discovery.js +0 -6
  5. package/dist/DogfoodRuntime.d.ts +124 -0
  6. package/dist/DogfoodRuntime.js +273 -0
  7. package/dist/FeedbackModal.js +347 -61
  8. package/dist/LoginScreen.d.ts +1 -5
  9. package/dist/LoginScreen.js +2 -6
  10. package/dist/MachinePickerScreen.d.ts +1 -3
  11. package/dist/MachinePickerScreen.js +6 -18
  12. package/dist/P2PClient.d.ts +116 -3
  13. package/dist/P2PClient.js +273 -3
  14. package/dist/P2PDogfoodDriver.d.ts +12 -0
  15. package/dist/P2PDogfoodDriver.js +118 -0
  16. package/dist/PairDeviceModal.d.ts +2 -3
  17. package/dist/ShakeDetector.js +31 -17
  18. package/dist/VibeChatScreen.d.ts +11 -1
  19. package/dist/VibeChatScreen.js +200 -41
  20. package/dist/YaverFeedback.d.ts +34 -0
  21. package/dist/YaverFeedback.js +124 -19
  22. package/dist/YaverModeBadge.d.ts +22 -0
  23. package/dist/YaverModeBadge.js +219 -0
  24. package/dist/__tests__/AuthDevices.test.js +1 -48
  25. package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
  26. package/dist/__tests__/DogfoodRuntime.test.js +116 -0
  27. package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
  28. package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
  29. package/dist/__tests__/ReportIdentity.test.d.ts +25 -1
  30. package/dist/__tests__/ReportIdentity.test.js +34 -22
  31. package/dist/__tests__/ShakeDetectorWeb.test.d.ts +1 -0
  32. package/dist/__tests__/ShakeDetectorWeb.test.js +27 -0
  33. package/dist/__tests__/YaverFeedback.test.js +13 -3
  34. package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
  35. package/dist/__tests__/deviceDogfood.test.js +86 -0
  36. package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
  37. package/dist/__tests__/dogfoodPolicy.test.js +33 -0
  38. package/dist/_core/ansi.d.ts +117 -0
  39. package/dist/_core/ansi.js +468 -0
  40. package/dist/_core/ansi.test.d.ts +1 -0
  41. package/dist/_core/ansi.test.js +225 -0
  42. package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
  43. package/dist/_core/buildFeedbackPrompt.js +18 -72
  44. package/dist/_core/constants.d.ts +19 -6
  45. package/dist/_core/constants.js +20 -7
  46. package/dist/_core/device.d.ts +9 -23
  47. package/dist/_core/device.js +13 -28
  48. package/dist/_core/endpoints.d.ts +0 -7
  49. package/dist/_core/endpoints.js +0 -7
  50. package/dist/_core/index.d.ts +4 -0
  51. package/dist/_core/index.js +4 -0
  52. package/dist/_core/remoteless.d.ts +44 -0
  53. package/dist/_core/remoteless.js +75 -0
  54. package/dist/_core/trace.d.ts +47 -0
  55. package/dist/_core/trace.js +38 -0
  56. package/dist/_core/trace.test.d.ts +1 -0
  57. package/dist/_core/trace.test.js +60 -0
  58. package/dist/auth.d.ts +3 -60
  59. package/dist/auth.js +6 -88
  60. package/dist/deviceDogfood.d.ts +53 -0
  61. package/dist/deviceDogfood.js +137 -0
  62. package/dist/dogfoodPolicy.d.ts +25 -0
  63. package/dist/dogfoodPolicy.js +24 -0
  64. package/dist/index.d.ts +13 -4
  65. package/dist/index.js +24 -8
  66. package/dist/reloadActions.js +2 -2
  67. package/dist/types.d.ts +50 -7
  68. package/package.json +12 -3
  69. package/src/AuthOverlay.tsx +20 -106
  70. package/src/Discovery.ts +0 -6
  71. package/src/DogfoodRuntime.ts +373 -0
  72. package/src/FeedbackModal.tsx +445 -67
  73. package/src/LoginScreen.tsx +2 -22
  74. package/src/MachinePickerScreen.tsx +6 -21
  75. package/src/P2PClient.ts +347 -4
  76. package/src/P2PDogfoodDriver.ts +132 -0
  77. package/src/PairDeviceModal.tsx +2 -3
  78. package/src/ShakeDetector.ts +31 -18
  79. package/src/VibeChatScreen.tsx +232 -42
  80. package/src/YaverFeedback.ts +133 -22
  81. package/src/YaverModeBadge.tsx +234 -0
  82. package/src/__tests__/AuthDevices.test.ts +1 -52
  83. package/src/__tests__/DogfoodRuntime.test.ts +135 -0
  84. package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
  85. package/src/__tests__/ReportIdentity.test.ts +36 -27
  86. package/src/__tests__/ShakeDetectorWeb.test.ts +30 -0
  87. package/src/__tests__/YaverFeedback.test.ts +15 -3
  88. package/src/__tests__/deviceDogfood.test.ts +77 -0
  89. package/src/__tests__/dogfoodPolicy.test.ts +34 -0
  90. package/src/_core/ansi.test.ts +250 -0
  91. package/src/_core/ansi.ts +475 -0
  92. package/src/_core/buildFeedbackPrompt.ts +18 -95
  93. package/src/_core/constants.ts +20 -6
  94. package/src/_core/device.ts +13 -30
  95. package/src/_core/endpoints.ts +0 -7
  96. package/src/_core/index.ts +4 -0
  97. package/src/_core/remoteless.ts +110 -0
  98. package/src/_core/trace.test.ts +58 -0
  99. package/src/_core/trace.ts +75 -0
  100. package/src/auth.ts +7 -156
  101. package/src/deviceDogfood.ts +171 -0
  102. package/src/dogfoodPolicy.ts +40 -0
  103. package/src/index.ts +40 -11
  104. package/src/reloadActions.ts +2 -2
  105. package/src/types.ts +45 -7
  106. package/dist/GuestOnboardingScreen.d.ts +0 -8
  107. package/dist/GuestOnboardingScreen.js +0 -282
  108. package/src/GuestOnboardingScreen.tsx +0 -307
@@ -0,0 +1,475 @@
1
+ // AUTO-SYNCED from shared/client-core/src/ansi.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.ts — ANSI/terminal-stream tokenizer shared by every Yaver surface.
7
+ *
8
+ * WHY THIS EXISTS (2026-08-09): the opencode runner's raw stdout is an ANSI
9
+ * VT stream (`\x1b[0m`, `$` prompts, `> build · <model>` banners, git diff
10
+ * +/- lines, 256-color + truecolor SGR codes, box-drawing TUIs). The
11
+ * dashboard and the mobile app both flattened it to plain text with
12
+ * stripAnsi — losing every colour the console has — while the xterm Terminal
13
+ * view kept the bytes. One shared tokenizer gives BOTH chat surfaces the
14
+ * console look from the SAME code path, so the two renderers can never drift
15
+ * (AGENTS.md: "one shared classifier, no copies"). The web and mobile
16
+ * renderers consume these tokens and paint them with their own primitives
17
+ * (spans / nested Text).
18
+ *
19
+ * This file is platform-neutral: no DOM, no RN. It lives in
20
+ * shared/client-core and is mirrored into mobile/src/_core via
21
+ * scripts/sync-client-core.sh (CI checks drift).
22
+ *
23
+ * Scope: the SGR family (\x1b[<params>m) plus the most common cursor/erase
24
+ * sequences (move, clear-line, clear-screen), which are dropped with a
25
+ * line-split marker so a TUI re-render reads as one block. OSC hyperlinks
26
+ * (\x1b]8;;url\x1b\\ ... \x1b]8;;\x1b\\) are extracted and carried on the
27
+ * token. Everything else is stripped.
28
+ */
29
+
30
+ export interface AnsiToken {
31
+ /** Plain text content of this run (escape sequences removed). */
32
+ text: string;
33
+ /** Optional OSC-8 hyperlink URL attached to the run. */
34
+ href?: string;
35
+ fg?: AnsiColor;
36
+ bg?: AnsiColor;
37
+ bold?: boolean;
38
+ dim?: boolean;
39
+ italic?: boolean;
40
+ underline?: boolean;
41
+ strike?: boolean;
42
+ }
43
+
44
+ export interface AnsiColor {
45
+ kind: "named" | "rgb" | "palette";
46
+ /** named: 0-15 (standard + bright). palette: 0-255 (xterm-256). */
47
+ index?: number;
48
+ /** rgb: [r,g,b]. */
49
+ rgb?: [number, number, number];
50
+ }
51
+
52
+ /** The 16 standard ANSI colours as RGB (bright variants at 8-15). */
53
+ export const ANSI_16_RGB: ReadonlyArray<readonly [number, number, number]> = [
54
+ [0, 0, 0], // 0 black
55
+ [194, 54, 33], // 1 red (opencode-ish)
56
+ [94, 164, 44], // 2 green
57
+ [163, 134, 52], // 3 yellow/orange
58
+ [0, 94, 168], // 4 blue
59
+ [101, 88, 186], // 5 magenta
60
+ [44, 142, 158], // 6 cyan
61
+ [190, 190, 190], // 7 white
62
+ [128, 128, 128], // 8 bright black
63
+ [255, 113, 89], // 9 bright red
64
+ [134, 214, 92], // 10 bright green
65
+ [229, 201, 84], // 11 bright yellow
66
+ [88, 156, 245], // 12 bright blue
67
+ [184, 146, 255], // 13 bright magenta
68
+ [112, 213, 226], // 14 bright cyan
69
+ [240, 240, 240], // 15 bright white
70
+ ];
71
+
72
+ /** The xterm-256 colour cube / greys (index 16-255). */
73
+ const PALETTE_RGB = (() => {
74
+ const out: [number, number, number][] = [];
75
+ // 16-231: 6x6x6 colour cube.
76
+ for (let i = 0; i < 216; i++) {
77
+ const r = Math.round((i / 36) % 6) * 51;
78
+ const g = Math.round((i / 6) % 6) * 51;
79
+ const b = (i % 6) * 51;
80
+ out.push([r, g, b]);
81
+ }
82
+ // 232-255: 24-step grey ramp.
83
+ for (let i = 0; i < 24; i++) {
84
+ const v = 8 + i * 10;
85
+ out.push([v, v, v]);
86
+ }
87
+ return out;
88
+ })();
89
+
90
+ /** Map an xterm-256 palette index to RGB. */
91
+ export function paletteRgb(index: number): [number, number, number] {
92
+ if (index >= 0 && index < 16) return [...ANSI_16_RGB[index]] as [number, number, number];
93
+ if (index >= 16 && index < 256) return PALETTE_RGB[index - 16];
94
+ return [190, 190, 190];
95
+ }
96
+
97
+ /** Truecolor/rgb approximation of a 256-colour index (used when a surface
98
+ * can't map the palette exactly). */
99
+ const OSC_LINK_OPEN = /\x1b\]8;;([^\x07]*)\x07/;
100
+
101
+ /** Schemes an OSC-8 hyperlink may carry. Anything else — `javascript:`,
102
+ * `data:`, `vbscript:`, a bare protocol-relative `//`, or a scheme-less
103
+ * string that React would still accept as a URL — is dropped so a
104
+ * prompt-injected link can never execute in the dashboard origin. This is
105
+ * the web-side half of the audit finding "OSC-8 `javascript:` href XSS"
106
+ * (docs/audits/webui-chat-vibing-gui-2026-08-12.md §6.1): the tokenizer is
107
+ * the single choke point every renderer (AnsiConsoleText, terminal views)
108
+ * shares, so sanitising here protects all of them.
109
+ */
110
+ // http(s):// and mailto: (mailto has no "//"). Everything else fails.
111
+ const OSC_LINK_SAFE = /^(https?:\/\/|mailto:)/i;
112
+
113
+ /** Returns the href to carry on the token, or "" when the URL is unsafe. */
114
+ function sanitizeOscLink(url: string): string {
115
+ const trimmed = (url || "").trim();
116
+ if (!trimmed) return "";
117
+ // `javascript:alert(1)` fails the allowlist; `//evil.com` is
118
+ // protocol-relative and must not become a same-origin jump; a bare
119
+ // `alert(1)` without a scheme is not a URL at all. Only explicit
120
+ // http(s):// or mailto: links pass.
121
+ if (!OSC_LINK_SAFE.test(trimmed)) return "";
122
+ return trimmed;
123
+ }
124
+
125
+ export interface TokenizeOptions {
126
+ /** Keep cursor/erase sequences as line breaks instead of dropping them
127
+ * silently (default true: a TUI repaint becomes a blank line). */
128
+ keepEraseLines?: boolean;
129
+ }
130
+
131
+ /**
132
+ * Tokenize an ANSI/VT stream into styled runs. Pure, deterministic,
133
+ * dependency-free — unit-tested in ansi.test.ts.
134
+ */
135
+ export function tokenizeAnsi(input: string, opts?: TokenizeOptions): AnsiToken[] {
136
+ if (!input) return [];
137
+ const keepEraseLines = opts?.keepEraseLines ?? true;
138
+ const tokens: AnsiToken[] = [];
139
+
140
+ let fg: AnsiColor | undefined;
141
+ let bg: AnsiColor | undefined;
142
+ let bold = false;
143
+ let dim = false;
144
+ let italic = false;
145
+ let underline = false;
146
+ let strike = false;
147
+ let buf = "";
148
+ // Current open OSC-8 link (the ESC\ BEL...ST form), if any.
149
+ let linkUrl: string | undefined;
150
+
151
+ const pushBuf = (href?: string) => {
152
+ if (!buf) return;
153
+ tokens.push({
154
+ text: buf,
155
+ ...(href || linkUrl ? { href: href || linkUrl } : {}),
156
+ ...(fg ? { fg } : {}),
157
+ ...(bg ? { bg } : {}),
158
+ ...(bold ? { bold: true } : {}),
159
+ ...(dim ? { dim: true } : {}),
160
+ ...(italic ? { italic: true } : {}),
161
+ ...(underline ? { underline: true } : {}),
162
+ ...(strike ? { strike: true } : {}),
163
+ });
164
+ buf = "";
165
+ };
166
+
167
+ const applySgr = (params: string) => {
168
+ if (!params) { // bare \x1b[m === reset
169
+ fg = undefined; bg = undefined; bold = false; dim = false;
170
+ italic = false; underline = false; strike = false;
171
+ return;
172
+ }
173
+ const parts = params.split(";");
174
+ let i = 0;
175
+ while (i < parts.length) {
176
+ const code = parts[i];
177
+ if (code === "") { fg = undefined; bg = undefined; bold = false; dim = false; italic = false; underline = false; strike = false; i++; continue; }
178
+ const n = parseInt(code, 10);
179
+ if (isNaN(n)) { i++; continue; }
180
+ switch (n) {
181
+ case 0: fg = undefined; bg = undefined; bold = false; dim = false; italic = false; underline = false; strike = false; break;
182
+ case 1: bold = true; break;
183
+ case 2: dim = true; break;
184
+ case 3: italic = true; break;
185
+ case 4: underline = true; break;
186
+ case 7: /* reverse video — swap fg/bg */ { const t = fg; fg = bg; bg = t; break; }
187
+ case 9: strike = true; break;
188
+ case 22: bold = false; dim = false; break;
189
+ case 23: italic = false; break;
190
+ case 24: underline = false; break;
191
+ case 29: strike = false; break;
192
+ case 30: case 31: case 32: case 33: case 34: case 35: case 36: case 37:
193
+ fg = { kind: "named", index: n - 30 }; break;
194
+ case 38: {
195
+ const t = parseExtendedColor(parts, i + 1);
196
+ if (t) { fg = t.color; i = t.next; }
197
+ break;
198
+ }
199
+ case 39: fg = undefined; break;
200
+ case 40: case 41: case 42: case 43: case 44: case 45: case 46: case 47:
201
+ bg = { kind: "named", index: n - 40 }; break;
202
+ case 48: {
203
+ const t = parseExtendedColor(parts, i + 1);
204
+ if (t) { bg = t.color; i = t.next; }
205
+ break;
206
+ }
207
+ case 49: bg = undefined; break;
208
+ case 90: case 91: case 92: case 93: case 94: case 95: case 96: case 97:
209
+ fg = { kind: "named", index: n - 90 + 8 }; break;
210
+ case 100: case 101: case 102: case 103: case 104: case 105: case 106: case 107:
211
+ bg = { kind: "named", index: n - 100 + 8 }; break;
212
+ default: /* unknown — ignore */ break;
213
+ }
214
+ i++;
215
+ }
216
+ };
217
+
218
+ /** Parse 38/48 extended colours: `;5;<idx>` (256) or `;2;r;g;b` (truecolor). */
219
+ const parseExtendedColor = (parts: string[], from: number): { color: AnsiColor; next: number } | null => {
220
+ const mode = parts[from];
221
+ if (mode === "5") {
222
+ const idx = parseInt(parts[from + 1] ?? "", 10);
223
+ if (!isNaN(idx)) return { color: { kind: "palette", index: idx }, next: from + 1 };
224
+ } else if (mode === "2") {
225
+ const r = parseInt(parts[from + 1] ?? "", 10);
226
+ const g = parseInt(parts[from + 2] ?? "", 10);
227
+ const b = parseInt(parts[from + 3] ?? "", 10);
228
+ if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return { color: { kind: "rgb", rgb: [r, g, b] }, next: from + 3 };
229
+ }
230
+ return null;
231
+ };
232
+
233
+ // Master scan: match SGR/control escapes OR OSC-8 hyperlink shells in one
234
+ // pass so link offsets can never drift from the text they wrap.
235
+ const MASTER = /(\x1b\]8;;[^\x07]*\x07[\s\S]*?\x1b\]8;;\x07)|(\x1b\[[0-9;:]*[A-Za-z])/g;
236
+ MASTER.lastIndex = 0;
237
+ let m: RegExpExecArray | null;
238
+ let prevEnd = 0;
239
+ while ((m = MASTER.exec(input)) !== null) {
240
+ const osc = m[1];
241
+ const esc = m[2];
242
+ const start = m.index;
243
+ // Accumulate the plain text since the previous escape into buf.
244
+ if (start > prevEnd) {
245
+ buf += input.slice(prevEnd, start);
246
+ }
247
+ if (osc) {
248
+ // OSC-8 hyperlink shell: \x1b]8;;<url>\x07<text>\x1b]8;;\x07
249
+ const open = osc.match(OSC_LINK_OPEN);
250
+ const url = sanitizeOscLink(open ? open[1] : "");
251
+ // Strip the shell, keep only the inner text.
252
+ const inner = osc.replace(/\x1b\]8;;[^\x07]*\x07/, "").replace(/\x1b\]8;;\x07$/, "");
253
+ buf += inner;
254
+ pushBuf(url || undefined);
255
+ linkUrl = undefined;
256
+ } else if (esc) {
257
+ const params = esc.slice(2, -1);
258
+ const cmd = esc.slice(-1);
259
+ if (cmd === "m") {
260
+ pushBuf();
261
+ applySgr(params);
262
+ } else {
263
+ // Non-SGR control (cursor move, erase, etc.). Keep text before it,
264
+ // then treat the sequence as a boundary.
265
+ pushBuf();
266
+ if (keepEraseLines && (cmd === "K" || cmd === "J" || cmd === "H" || cmd === "A")) {
267
+ tokens.push({ text: "\n" });
268
+ }
269
+ }
270
+ }
271
+ prevEnd = MASTER.lastIndex;
272
+ }
273
+ if (prevEnd < input.length) {
274
+ buf += input.slice(prevEnd);
275
+ }
276
+ pushBuf();
277
+
278
+ // Merge adjacent tokens with identical style so a stream that toggles
279
+ // `\x1b[0m` between runs (the opencode banner does this constantly) stays
280
+ // readable as contiguous text instead of fragmenting into per-escape runs.
281
+ const merged: AnsiToken[] = [];
282
+ for (const t of tokens) {
283
+ const last = merged[merged.length - 1];
284
+ if (
285
+ last &&
286
+ last.href === t.href &&
287
+ last.bold === t.bold &&
288
+ last.dim === t.dim &&
289
+ last.italic === t.italic &&
290
+ last.underline === t.underline &&
291
+ last.strike === t.strike &&
292
+ JSON.stringify(last.fg) === JSON.stringify(t.fg) &&
293
+ JSON.stringify(last.bg) === JSON.stringify(t.bg)
294
+ ) {
295
+ last.text += t.text;
296
+ } else {
297
+ merged.push({ ...t });
298
+ }
299
+ }
300
+
301
+ return merged;
302
+ }
303
+
304
+ /** Convenience: flatten tokens to plain text (strip all styling). */
305
+ export function ansiToPlain(input: string): string {
306
+ return tokenizeAnsi(input)
307
+ .map((t) => t.text)
308
+ .join("");
309
+ }
310
+
311
+ /** Split a token stream into lines, preserving per-token styling. */
312
+ export interface AnsiLine {
313
+ tokens: AnsiToken[];
314
+ }
315
+
316
+ export function tokenStreamToLines(tokens: AnsiToken[]): AnsiLine[] {
317
+ const lines: AnsiLine[] = [];
318
+ let current: AnsiToken[] = [];
319
+ for (const t of tokens) {
320
+ const parts = t.text.split("\n");
321
+ parts.forEach((seg, idx) => {
322
+ if (idx > 0) {
323
+ lines.push({ tokens: current });
324
+ current = [];
325
+ }
326
+ if (seg.length > 0) {
327
+ current.push({ ...t, text: seg });
328
+ }
329
+ });
330
+ }
331
+ if (current.length > 0) lines.push({ tokens: current });
332
+ return lines;
333
+ }
334
+
335
+ /**
336
+ * Structural console-line classification (opencode console look, 2026-08-09).
337
+ *
338
+ * The opencode runner's raw stream is full of shapes a plain-text render
339
+ * flattens: `> build · <model>` banner lines, `$ command` prompt lines, git
340
+ * patch output (`diff --git`, `+added`, `-removed`, `@@ hunk @@`), and
341
+ * tool/status prefixes. The terminal view renders these with xterm's own
342
+ * palette; the CHAT views on web + mobile used to strip every trace of them.
343
+ * One shared classifier lets both chat renderers paint the same console
344
+ * grammar from the same code path — a line is a hint, the renderer decides
345
+ * the exact colour, and the two surfaces can never drift.
346
+ *
347
+ * Pure, deterministic, unit-tested in ansi.test.ts.
348
+ */
349
+ export type AnsiLineHint =
350
+ | "plain" // nothing special
351
+ | "banner" // `> build · <model>` / `> plan · <model>` (opencode header)
352
+ | "prompt" // `$ command` (shell prompt the runner is about to run)
353
+ | "diff-header" // `diff --git a/… b/…`
354
+ | "diff-file" // `--- a/…` / `+++ b/…`
355
+ | "diff-hunk" // `@@ -1,2 +1,2 @@`
356
+ | "diff-add" // `+added` line in a patch
357
+ | "diff-del" // `-removed` line in a patch
358
+ | "tool-call"; // `⎿ run <cmd>` opencode tool-call tail or `✓`/`✗` status
359
+
360
+ const BANNER_RE = /^\s*>?\s*(build|plan|review|debug)\s*[·•|:]\s*\S+/;
361
+ const PROMPT_RE = /^\s*\$[ \t]/;
362
+ const DIFF_HEADER_RE = /^diff --git \S+ \S+$/;
363
+ const DIFF_FILE_RE = /^(\+\+\+|---) [ab]\/\S+/;
364
+ const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
365
+ const TOOL_TAIL_RE = /^\s*(⎿|└|├)\s*/;
366
+ const STATUS_RE = /^\s*[✓✗]\s+\S/;
367
+
368
+ export function classifyAnsiLine(plain: string): AnsiLineHint {
369
+ const s = plain.replace(/\s+$/g, "");
370
+ if (!s) return "plain";
371
+ if (BANNER_RE.test(s)) return "banner";
372
+ if (PROMPT_RE.test(s)) return "prompt";
373
+ if (DIFF_HEADER_RE.test(s)) return "diff-header";
374
+ if (DIFF_FILE_RE.test(s)) return "diff-file";
375
+ if (DIFF_HUNK_RE.test(s)) return "diff-hunk";
376
+ if (s.startsWith("+") && !s.startsWith("+++")) return "diff-add";
377
+ if (s.startsWith("-") && !s.startsWith("---")) return "diff-del";
378
+ if (TOOL_TAIL_RE.test(s) || STATUS_RE.test(s)) return "tool-call";
379
+ return "plain";
380
+ }
381
+
382
+ /**
383
+ * One-stop helper: tokenize a raw stream and return per-line hints aligned
384
+ * with the token lines, so a renderer can paint both colour runs and line
385
+ * grammar from a single pass.
386
+ */
387
+ export interface AnsiStyledLine {
388
+ hint: AnsiLineHint;
389
+ plain: string;
390
+ tokens: AnsiToken[];
391
+ }
392
+
393
+ export function styleAnsiLines(input: string): AnsiStyledLine[] {
394
+ const lines = tokenStreamToLines(tokenizeAnsi(input));
395
+ return lines.map((l) => {
396
+ const plain = l.tokens.map((t) => t.text).join("");
397
+ return { hint: classifyAnsiLine(plain), plain, tokens: l.tokens };
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Summarize a raw runner console for a small screen (phone / compact web
403
+ * pane). The full raw stream is megabytes of tool noise a human shouldn't
404
+ * have to read to find the answer; this is a DETERMINISTIC reducer — no LLM,
405
+ * no config:
406
+ *
407
+ * - `$ cmd` prompt echoes, runner-config banners (workdir/model/…), git
408
+ * diff hunks and punctuation-only TUI redraw edges are dropped.
409
+ * - 3+ consecutive identical lines collapse to one.
410
+ * - A line/byte budget bounds the render (a RUNNING task gets a tight
411
+ * budget — the user is watching the runner, not reading it; a finished
412
+ * task's tail gets a bigger one so the answer stays readable).
413
+ * - A trailing marker says how many lines were collapsed.
414
+ *
415
+ * Classification runs on ANSI-stripped text so escapes-prefixed lines still
416
+ * match; the KEPT lines keep their escapes intact for AnsiConsoleText to
417
+ * paint. Budgets default to mobile-sized; pass larger ones for web.
418
+ */
419
+ export interface SummarizeConsoleOptions {
420
+ /** Max lines to keep. Default: 40 running / 200 finished. */
421
+ budgetLines?: number;
422
+ /** Max characters to keep. Default: 6 KiB running / 24 KiB finished. */
423
+ budgetChars?: number;
424
+ }
425
+
426
+ const CONSOLE_NOISE_RE = [
427
+ /^\$\s+/, // `$ cmd` echo
428
+ /^(workdir|model|provider|approval|sandbox|reasoning effort|session id|project path|project):/i, // runner config banners
429
+ /^(diff --git|index [0-9a-f]+\.\.[0-9a-f]+|@@ |--- |\+\+\+ )/, // diff hunks
430
+ /^[{}[\];(),.=><:+\-/*\\|'"`_~]+$/, // TUI redraw edges
431
+ ];
432
+
433
+ export function summarizeRawConsole(raw: string, running: boolean, opts?: SummarizeConsoleOptions): string {
434
+ if (!raw) return "";
435
+ const budgetLines = opts?.budgetLines ?? (running ? 40 : 200);
436
+ const budgetChars = opts?.budgetChars ?? (running ? 6 * 1024 : 24 * 1024);
437
+ const lines = raw.split("\n");
438
+ // KEEP THE TAIL, NOT THE HEAD (2026-08-13, web chat live-console bug):
439
+ // the old loop dropped NEW lines once the budget was reached, so a
440
+ // running task past ~150 lines froze its console at the START of the
441
+ // run — the header said "N LINES · LIVE" while every line after the
442
+ // budget never rendered, and auto-scroll had nothing to follow. A live
443
+ // console shows the newest output; evict the OLDEST kept line instead
444
+ // of rejecting the newest.
445
+ const kept: string[] = [];
446
+ let keptChars = 0;
447
+ let dropped = 0;
448
+ let prevKey = "";
449
+ let runLen = 0;
450
+ for (const rawLine of lines) {
451
+ const plain = ansiToPlain(rawLine).trim();
452
+ if (!plain) continue;
453
+ if (CONSOLE_NOISE_RE.some((re) => re.test(plain))) { dropped += 1; continue; }
454
+ if (plain === prevKey) {
455
+ runLen += 1;
456
+ if (runLen > 2) { dropped += 1; continue; } // 3+ repeats → one
457
+ } else {
458
+ prevKey = plain;
459
+ runLen = 1;
460
+ }
461
+ if (kept.length >= budgetLines || keptChars >= budgetChars) {
462
+ // Budget full: make room for the newest line by evicting the oldest
463
+ // kept line (tracking its char weight so the char budget stays
464
+ // accurate). This is what makes a streaming console follow the tail.
465
+ dropped += 1;
466
+ const evicted = kept.shift();
467
+ if (evicted) keptChars -= evicted.length + 1;
468
+ }
469
+ kept.push(rawLine);
470
+ keptChars += rawLine.length + 1;
471
+ }
472
+ let out = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
473
+ if (dropped > 0) out = `${out}\n… ${dropped} noisy lines collapsed`;
474
+ return out;
475
+ }
@@ -1,102 +1,25 @@
1
- // buildFeedbackPrompt shared prompt enrichment used by every Yaver
2
- // feedback surface, in-Yaver native pane (mirrored in Swift + Kotlin)
3
- // AND the standalone RN feedback SDK (this file). Keep all three
4
- // implementations in lockstep — the wording is what the AI on the
5
- // remote is conditioned to expect.
6
- //
7
- // The bare user text on its own loses crucial context: WHICH app the
8
- // user is testing, WHICH screen they're looking at, and whether a
9
- // screenshot is attached for visual reference. Without that the agent
10
- // guesses, edits the wrong project, or asks clarifying questions
11
- // instead of acting. The wrapper below tells the agent:
12
- // - this feedback comes from the in-app drawer while the user is
13
- // mid-test,
14
- // - which project the user is in (when known),
15
- // - that the FIRST attached image (when present) is a snapshot of
16
- // the current screen — open it to see what the user is pointing
17
- // at,
18
- // - that changes should be applied to that project's source +
19
- // saved so the user can trigger a Hermes reload to see them.
20
- //
21
- // Cross-reference: mobile/ios/Yaver/YaverFeedbackPane.swift's
22
- // `buildFeedbackPrompt` and mobile/android/.../YaverFeedbackPane.kt's
23
- // `buildFeedbackPrompt`. All three must match.
1
+ // AUTO-SYNCED from shared/client-core/src/buildFeedbackPrompt.ts.
2
+ // DO NOT EDIT IN PLACE. Edit the source and re-run
3
+ // scripts/sync-client-core.sh. CI checks drift via `--check`.
24
4
 
25
- export interface BuildFeedbackPromptInput {
5
+ export interface FeedbackPromptInput {
26
6
  userPrompt: string;
27
- /** Hot-Reload project name when running inside Yaver mobile, OR
28
- * the host app's bundle/package name when running standalone. */
29
7
  projectName?: string;
30
- /** Absolute path on the host where the project lives (only known
31
- * when running inside Yaver mobile via Hot Reload). */
32
8
  projectPath?: string;
33
- /** True when the caller has attached a screenshot of the current
34
- * screen as the first image in the task's images array. */
35
- hasScreenshot: boolean;
9
+ hasScreenshot?: boolean;
36
10
  }
37
11
 
38
- export function buildFeedbackPrompt(input: BuildFeedbackPromptInput): string {
39
- const userPrompt = input.userPrompt ?? "";
40
- const projectName = (input.projectName ?? "").trim();
41
- const projectPath = (input.projectPath ?? "").trim();
42
- const hasScreenshot = !!input.hasScreenshot;
43
-
44
- const lines: string[] = [];
45
- lines.push("[Mobile feedback from inside Yaver]");
46
- lines.push(
47
- "The user is providing this feedback while running a mobile app inside the Yaver mobile container " +
48
- "and is currently looking at a specific screen of that app."
49
- );
50
- lines.push("");
51
- if (projectName || projectPath) {
52
- lines.push("App being tested:");
53
- if (projectName) lines.push(` name: ${projectName}`);
54
- if (projectPath) lines.push(` path: ${projectPath}`);
55
- lines.push("");
56
- }
57
- if (hasScreenshot) {
58
- lines.push(
59
- "A screenshot of the current screen is attached as the first image. " +
60
- "Open it before deciding what to change — the user is pointing at what they SEE, " +
61
- "not necessarily what is named most prominently in the source."
62
- );
63
- lines.push("");
64
- } else {
65
- lines.push("(The user chose not to attach a screenshot for this round.)");
66
- lines.push("");
67
- }
68
- lines.push("Operation contract:");
69
- lines.push(
70
- "1. Locate the file(s) responsible for what the user described and EDIT them in place. " +
71
- "Save the changes — that is the deliverable."
72
- );
73
- lines.push(
74
- "2. Stream a CONCISE Claude-Code / Codex-style narration as you work: " +
75
- "one short line per step (e.g. \"Reading app/index.tsx\", " +
76
- "\"Editing safe.backgroundColor\", \"Saved app/index.tsx\"). Show small diffs only — " +
77
- "never dump entire files, never paste node_modules contents, never echo build / install logs."
78
- );
79
- lines.push(
80
- "3. Do NOT run npm install / yarn / pnpm / git clone / cargo build / docker pull or any other " +
81
- "long-running install / fetch command. The repo is already prepared on this machine. " +
82
- "If a dependency is genuinely missing, say so in one line and stop — the user will install it."
83
- );
84
- lines.push(
85
- "4. Do NOT trigger a Hermes reload yourself. The user has a Reload button in the drawer " +
86
- "and decides when to refresh."
87
- );
88
- lines.push(
89
- "5. Keep total output under a few hundred lines. Heavy ripgrep / find / cat with no filter " +
90
- "are usually the wrong tool — use targeted reads."
91
- );
92
- if (!projectName && !projectPath) {
93
- lines.push(
94
- "6. If you can identify the project from the prompt or the screenshot, work there. " +
95
- "Otherwise ask the user briefly which project to target — one short line, no exhaustive list."
96
- );
97
- }
98
- lines.push("");
99
- lines.push("User feedback:");
100
- lines.push(userPrompt);
101
- return lines.join("\n");
12
+ /** Shared, deterministic feedback-task prompt used by every client surface. */
13
+ export function buildFeedbackPrompt(input: FeedbackPromptInput): string {
14
+ const request = String(input.userPrompt || '').trim();
15
+ const context: string[] = [];
16
+ if (input.projectName?.trim()) context.push(`Project: ${input.projectName.trim()}`);
17
+ if (input.projectPath?.trim()) context.push(`Working directory: ${input.projectPath.trim()}`);
18
+ if (input.hasScreenshot) context.push('A screenshot is attached; use it as visual evidence.');
19
+ return [
20
+ 'Investigate and implement this feedback request in the named project.',
21
+ ...context,
22
+ '',
23
+ request || 'Inspect the attached evidence and fix the visible issue.',
24
+ ].join('\n');
102
25
  }
@@ -57,13 +57,27 @@ export const DEFAULT_BEACON_UDP_PORT = 19837;
57
57
  /**
58
58
  * How old an agent's last heartbeat can be before the device is
59
59
  * considered offline. Mirrors `backend/convex/devices.ts` so
60
- * Convex + every client agree on the same threshold. The agent
61
- * heartbeats every 5 min (see `desktop/agent/main.go::heartbeatLoop`),
62
- * so 6 min tolerates one missed beat + 60 s of jitter without
63
- * flapping. Sub-minute death detection comes from the P2P bus, not
64
- * this threshold.
60
+ * Convex + every client agree on the same threshold.
61
+ *
62
+ * 900_000, not 90_000. This file said 90_000 while the authority named in the
63
+ * comment `backend/convex/devices.ts:112`, `const HEARTBEAT_STALE_MS = 900 *
64
+ * 1000` — and `mobile/src/_core/constants.ts` both said 900_000. A 10x drift in
65
+ * the constant whose own docstring promises it does not drift is exactly the
66
+ * "green on one, yellow on the other" glitch described above (2026-07-20).
67
+ *
68
+ * Sub-minute death detection is not this threshold's job — it comes from the
69
+ * P2P bus and from an actual reachability probe. Shortening this here would
70
+ * only make a live box flap offline between 5-minute heartbeats.
71
+ */
72
+ export const HEARTBEAT_STALE_MS = 900_000;
73
+
74
+ /**
75
+ * How long after the last relay-presence ping a device still counts as
76
+ * having a "live bus signal". Same product number as the web dashboard's
77
+ * hasRecentLiveSignal default (`web/lib/device-lifecycle.ts`, maxAgeMs =
78
+ * 360_000) so the phone and the browser agree on what "live" means.
65
79
  */
66
- export const HEARTBEAT_STALE_MS = 360_000;
80
+ export const BUS_PRESENCE_STALE_MS = 360_000;
67
81
 
68
82
  /**
69
83
  * How long after the last UDP beacon an agent is still considered