Skip to content

Commit cf768c8

Browse files
authored
feat(self-driving): fix the onboarding papercuts a customer run surfaced (#1057)
1 parent ef4978c commit cf768c8

12 files changed

Lines changed: 447 additions & 79 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"axios": "1.7.4",
4444
"fast-glob": "^3.3.3",
4545
"fflate": "^0.8.3",
46+
"fuse.js": "^7.5.0",
4647
"glob": "9.3.5",
4748
"ink": "^6.8.0",
4849
"inquirer": "^6.2.0",

‎pnpm-lock.yaml‎

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/lib/programs/self-driving/content/index.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { NO_DEFAULT_LIMIT, PRICING_LONG } from './pricing.js';
12
/**
23
* Self-driving learn-deck — the narrative script played while the agent
34
* sets up Self-driving. Teaches the vocabulary ladder (signal source →
@@ -175,9 +176,15 @@ export const getContentBlocks = (store?: WizardStore): ContentBlock[] => {
175176
},
176177

177178
{
178-
content:
179-
'That PR is the only thing you pay for: a flat $15 per report that ships a PR. Watching is free.',
180-
pause: 6000,
179+
// No lead-in: PRICING_LONG already names the PR as what's charged, and the
180+
// pane only fits ~148 chars per beat.
181+
content: PRICING_LONG,
182+
pause: 5000,
183+
},
184+
185+
{
186+
content: NO_DEFAULT_LIMIT,
187+
pause: 5000,
181188
},
182189

183190
CLEAR,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Self-driving pricing, in one place. It's stated at six points across the run (intro, tips,
3+
* learn deck, outro), and a price quoted six ways is a price the reader stops trusting.
4+
*
5+
* Careful with the word "free". The tracking this wizard installs bills as ordinary PostHog
6+
* usage, and scouts are heading for usage-based pricing too — so anything called free here is
7+
* a promise with an expiry date. State what the PR costs and leave the rest to usage-based
8+
* billing, which stays true either side of that change.
9+
*/
10+
11+
/** Flat USD charge per report that ships a pull request. */
12+
export const PRICE_PER_PR_USD = 15;
13+
14+
/** One line, for a screen that only has room to say what it costs. */
15+
export const PRICING_SHORT = `Agents charge a flat $${PRICE_PER_PR_USD} per pull request they ship.`;
16+
17+
/** The same fact at length, plus the bit people are surprised by later. */
18+
export const PRICING_LONG =
19+
`A report that ships a pull request costs a flat $${PRICE_PER_PR_USD}. ` +
20+
`Everything else follows your usual PostHog usage-based pricing.`;
21+
22+
/**
23+
* Nothing caps the spend unless you set it, which is worth saying out loud. Checked against
24+
* billing: `custom_limits_map` starts empty and a product with no custom limit resolves to
25+
* `usage_limit: None`, i.e. uncapped. A free plan is capped by its own allocation instead,
26+
* hence the qualifier.
27+
*/
28+
export const NO_DEFAULT_LIMIT =
29+
'A paid plan has no monthly cap until you set one. Add a PR limit in your inbox sidebar, ' +
30+
'and agents pause when they hit it.';

‎src/lib/programs/self-driving/content/tips.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { NO_DEFAULT_LIMIT, PRICING_LONG } from './pricing.js';
2+
13
/**
24
* Sidebar tips for the self-driving run — short footnotes on the
35
* vocabulary ladder (signal source → scout → signal → report → measured
@@ -44,8 +46,7 @@ export const SELF_DRIVING_TIPS: Tip[] = [
4446
{
4547
id: 'pricing',
4648
title: 'What it costs',
47-
description:
48-
'Watching is free. You pay a flat $15 only when a report ships a PR.',
49+
description: `${PRICING_LONG} ${NO_DEFAULT_LIMIT}`,
4950
},
5051
{
5152
id: 'work-anywhere',

‎src/lib/programs/self-driving/index.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
getSelfDrivingDetectedTools,
1111
} from './detect.js';
1212
import { buildSelfDrivingPrompt } from './prompt.js';
13+
import {
14+
NO_DEFAULT_LIMIT,
15+
PRICE_PER_PR_USD,
16+
PRICING_LONG,
17+
} from './content/pricing.js';
1318
import { getTips } from './content/tips.js';
1419
import { getContentBlocks } from './content/index.js';
1520

@@ -90,13 +95,12 @@ const buildRun = (session: WizardSession): Promise<ProgramRun> =>
9095
items: [
9196
'Investigate reports with the agent',
9297
'Tag teammates to loop them in',
93-
'Kick off a PR when you like the proposed fix ($15 flat)',
98+
`Kick off a PR when you like the proposed fix ($${PRICE_PER_PR_USD} flat)`,
99+
'Cap the spend with a monthly PR limit in the sidebar',
94100
'Or work from Slack (tag @PostHog) and MCP',
95101
],
96102
},
97-
body:
98-
'Pricing: scouts, signals, and reports are free. You pay a flat ' +
99-
'$15 only when a report ships a PR.',
103+
body: `${PRICING_LONG} ${NO_DEFAULT_LIMIT}`,
100104
reportFile: REPORT_FILE,
101105
};
102106
},

‎src/lib/programs/self-driving/prompt.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ STEP 6 — Configure the scout troop. (skill: "Scouts")
184184
whole troop, including step 6b's custom scouts, stays at or under
185185
ten enabled scouts.
186186
187+
Then tell the user what the troop is, before STEP 6b asks them to add
188+
to it. Scouts are PostHog's built-in ones, and the user has not seen
189+
the list: without it, STEP 6b asks them to approve additions to a
190+
baseline they cannot picture. Send one short message naming each
191+
scout you enabled with a plain one-line description of what it
192+
watches, and say how many you left disabled and that they can turn
193+
those on later from the inbox. No wizard_ask here, it is not a
194+
question. Keep each line to one sentence and do not restate the
195+
skill's internals.
196+
187197
STEP 6b — Design custom scouts for this product. (skill: "Custom scouts")
188198
You are the only actor that has read this repo — turn that into
189199
coverage per the skill: a real gap analysis of the project's
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
buildPickerIndex,
3+
rankOptions,
4+
type Searchable,
5+
} from '@ui/tui/primitives/picker-filter';
6+
7+
const OPTIONS: Searchable[] = [
8+
{ label: 'GitHub issues', hint: 'Issues opened on your repos' },
9+
{ label: 'GitHub discussions', hint: 'Q&A threads' },
10+
{ label: 'Error tracking', hint: 'Exceptions captured by PostHog' },
11+
{ label: 'Session replay', hint: 'Recorded sessions' },
12+
{ label: 'Linear issues', hint: 'Tickets from Linear' },
13+
{ label: 'Stripe', hint: 'Payments and subscriptions' },
14+
{ label: 'Zendesk tickets', hint: 'Support conversations' },
15+
{ label: 'Snowflake', description: 'Warehouse source' },
16+
];
17+
18+
function rank(query: string): string[] | null {
19+
const ranked = rankOptions(buildPickerIndex(OPTIONS), OPTIONS, query);
20+
return ranked?.map((option) => option.label) ?? null;
21+
}
22+
23+
describe('rankOptions', () => {
24+
it('returns null for an empty or whitespace-only query, meaning "unfiltered"', () => {
25+
expect(rank('')).toBeNull();
26+
expect(rank(' ')).toBeNull();
27+
});
28+
29+
it('ANDs terms, so a query spanning label words narrows to one option', () => {
30+
expect(rank('git iss')).toEqual(['GitHub issues']);
31+
});
32+
33+
it('matches case-insensitively', () => {
34+
expect(rank('GITHUB')).toEqual(['GitHub issues', 'GitHub discussions']);
35+
});
36+
37+
it('searches hint and description, not just the label', () => {
38+
expect(rank('exceptions')).toEqual(['Error tracking']);
39+
expect(rank('warehouse')).toEqual(['Snowflake']);
40+
});
41+
42+
it('keeps the list order for literal matches rather than reordering by score', () => {
43+
expect(rank('issues')).toEqual(['GitHub issues', 'Linear issues']);
44+
});
45+
46+
it('falls back to fuzzy matching when nothing matches literally', () => {
47+
// Dropped vowels: no substring match anywhere, so Fuse takes over and the
48+
// intended option has to come back first.
49+
expect(rank('gthb')?.[0]).toBe('GitHub discussions');
50+
expect(rank('zndsk')?.[0]).toBe('Zendesk tickets');
51+
});
52+
53+
it('does not use the fuzzy pass when a literal match exists', () => {
54+
// "replay" fuzzy-matches half the list at this threshold; the literal pass
55+
// must win outright so the common case stays tight.
56+
expect(rank('replay')).toEqual(['Session replay']);
57+
});
58+
59+
it('returns an empty list when even the fuzzy pass finds nothing', () => {
60+
expect(rank('qqqqqq')).toEqual([]);
61+
});
62+
});

‎src/ui/tui/hooks/keyboard-hints-utils.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ export enum KeyMatch {
1818
Return = 'return',
1919
Escape = 'escape',
2020
Space = 'space',
21+
Backspace = 'backspace',
22+
/**
23+
* Any single printable character typed without a modifier. Broad by design,
24+
* so a binding using it must sit last — the first match wins, which is what
25+
* lets a specific char binding still take precedence over free typing.
26+
*/
27+
Printable = 'printable',
2128
}
2229

2330
/** A key match: either a KeyMatch enum value or a literal character string (e.g. 'a', 's'). */
@@ -30,6 +37,8 @@ const DEFAULT_PRIORITY: Record<string, number> = {
3037
[KeyMatch.LeftArrow]: 1,
3138
[KeyMatch.RightArrow]: 1,
3239
[KeyMatch.Space]: 10,
40+
[KeyMatch.Printable]: 12,
41+
[KeyMatch.Backspace]: 13,
3342
[KeyMatch.Escape]: 20,
3443
[KeyMatch.Return]: 21,
3544
};
@@ -63,6 +72,19 @@ export function matchesKey(
6372
return !!key.escape;
6473
case KeyMatch.Space:
6574
return input === ' ';
75+
case KeyMatch.Backspace:
76+
return !!key.backspace || !!key.delete;
77+
case KeyMatch.Printable:
78+
// Ink sets a flag for every non-text key while still passing a non-empty
79+
// `input`, so any flag but shift rules out typing.
80+
// Any single non-control character, not just ASCII — a filter query can
81+
// hold an accented or non-Latin letter.
82+
return (
83+
input.length === 1 &&
84+
input >= ' ' &&
85+
input !== '\x7f' &&
86+
Object.entries(key).every(([flag, on]) => !on || flag === 'shift')
87+
);
6688
default:
6789
return input === m;
6890
}

0 commit comments

Comments
 (0)