Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/ui/tui/playground/PlaygroundApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { AuditChecksDemo } from './demos/AuditChecksDemo.js';
import { LearnDeckDemo } from './demos/LearnDeckDemo.js';
import { EndScreensDemo } from './demos/EndScreensDemo.js';
import { AiOptInDemo } from './demos/AiOptInDemo.js';
import { AskModalDemo } from './demos/AskModalDemo.js';

interface PlaygroundAppProps {
store: WizardStore;
Expand All @@ -32,6 +33,7 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => {
const tabs = [
{ id: 'layout', label: 'Layout', component: <LayoutDemo /> },
{ id: 'input', label: 'Input', component: <InputDemo /> },
{ id: 'ask-modal', label: 'Ask modal', component: <AskModalDemo /> },
{ id: 'progress', label: 'Progress', component: <ProgressDemo /> },
{ id: 'logs', label: 'Logs', component: <LogDemo /> },
{
Expand Down
187 changes: 187 additions & 0 deletions src/ui/tui/playground/demos/AskModalDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* AskModalDemo — Pickers rendered inside an ask-style ModalOverlay, the way
* WizardAskScreen composes them: prompt paragraph in the modal body, then a
* label-less PickerMenu. Exercises the viewport height cap (MAX_LIST_ROWS)
* and the no-label spacing (no stray blank lines under the paragraph).
*/

import { Box, Text } from 'ink';
import { useState, type ReactNode } from 'react';
import {
ModalOverlay,
PickerMenu,
GroupedPickerMenu,
} from '@ui/tui/primitives/index';
import { Colors, Icons } from '@ui/tui/styles';
import { LONG_OPTIONS } from './InputDemo.js';

enum DemoStep {
MultiLong = 'multi-long',
MultiDescriptions = 'multi-descriptions',
Single = 'single',
Grouped = 'grouped',
Done = 'done',
}

const DESCRIBED_OPTIONS = [
{
label: 'Session replay',
value: 'replay',
description:
'Record and replay real user sessions to see exactly what happened before an error or a rage click.',
},
{
label: 'Feature flags',
value: 'flags',
description:
'Roll features out gradually, target by cohort, and kill misbehaving code paths without a deploy.',
},
{
label: 'Error tracking',
value: 'errors',
description: 'Capture exceptions with stack traces and source maps.',
},
];

const GROUPED = {
'Issue trackers': [
{ label: 'GitHub Issues', value: 'github' },
{ label: 'Linear', value: 'linear' },
{ label: 'Jira', value: 'jira' },
{ label: 'Shortcut', value: 'shortcut' },
],
'Error monitoring': [
{ label: 'Sentry', value: 'sentry' },
{ label: 'Rollbar', value: 'rollbar' },
{ label: 'Bugsnag', value: 'bugsnag' },
{ label: 'Honeybadger', value: 'honeybadger' },
],
'Support desks': [
{ label: 'Zendesk', value: 'zendesk' },
{ label: 'Freshdesk', value: 'freshdesk' },
{ label: 'Front', value: 'front' },
{ label: 'Intercom', value: 'intercom' },
],
Observability: [
{ label: 'Datadog', value: 'datadog' },
{ label: 'New Relic', value: 'newrelic' },
{ label: 'Grafana', value: 'grafana' },
{ label: 'Splunk', value: 'splunk' },
],
};

/** Mirrors WizardAskScreen's shape: paragraph body, then a label-less input. */
const AskModal = ({
prompt,
children,
}: {
prompt: string;
children: ReactNode;
}) => (
<ModalOverlay
borderColor={Colors.accent}
title={`${Icons.diamond} self-driving-setup`}
titleColor={Colors.accent}
width={72}
>
<Box flexDirection="column">
<Text>{prompt}</Text>
</Box>
<Box marginTop={1}>{children}</Box>
</ModalOverlay>
);

export const AskModalDemo = () => {
const [step, setStep] = useState<DemoStep>(DemoStep.MultiLong);
const [results, setResults] = useState<string[]>([]);

const advance = (result: string, next: DemoStep) => {
setResults((prev) => [...prev, result]);
setStep(next);
};

if (step === DemoStep.MultiLong) {
return (
<AskModal prompt="Self-driving can also watch your other tools and investigate and fix the problems they surface. Which of these do you use?">
<PickerMenu
key={step}
mode="multi"
options={LONG_OPTIONS}
onSelect={(values) => {
const arr = Array.isArray(values) ? values : [values];
advance(
`Multi (long): ${arr.length} picked`,
DemoStep.MultiDescriptions,
);
}}
/>
</AskModal>
);
}

if (step === DemoStep.MultiDescriptions) {
return (
<AskModal prompt="Besides analytics, which PostHog products should the agent set up while it's in the codebase?">
<PickerMenu
key={step}
mode="multi"
optionMarginBottom={1}
options={DESCRIBED_OPTIONS}
onSelect={(values) => {
const arr = Array.isArray(values) ? values : [values];
advance(
`Multi (descriptions): ${arr.join(', ') || 'none'}`,
DemoStep.Single,
);
}}
/>
</AskModal>
);
}

if (step === DemoStep.Single) {
return (
<AskModal prompt="Your project has both an app and a marketing site. Which one should the agent instrument first?">
<PickerMenu<string>
options={[
{ label: 'The app', value: 'app', hint: 'src/app' },
{ label: 'The marketing site', value: 'marketing', hint: 'www/' },
{ label: 'Both, app first', value: 'both' },
]}
onSelect={(value) => {
advance(`Single: ${String(value)}`, DemoStep.Grouped);
}}
/>
</AskModal>
);
}

if (step === DemoStep.Grouped) {
return (
<AskModal prompt="Grouped variant of the same ask — categories with headers, everything pre-selected:">
<GroupedPickerMenu
groups={GROUPED}
onSelect={(values) => {
advance(`Grouped: ${values.length} kept`, DemoStep.Done);
}}
/>
</AskModal>
);
}

return (
<Box flexDirection="column">
<Text bold color={Colors.accent}>
Ask Modal Demo — Results
</Text>
<Box height={1} />
{results.map((r, i) => (
<Text key={i} color={Colors.success}>
{'✔'} {r}
</Text>
))}
<Box height={1} />
<Text dimColor>Switch away from this tab and back to restart.</Text>
</Box>
);
};
69 changes: 69 additions & 0 deletions src/ui/tui/playground/demos/InputDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,55 @@ import { Colors } from '@ui/tui/styles';
enum DemoStep {
Single = 'single',
Multi = 'multi',
MultiLong = 'multi-long',
Confirm = 'confirm',
Done = 'done',
}

// A long single-column multi-select — more options than a normal terminal can
// show at once — so the playground exercises PickerMenu's viewport paging
// (the "↑/↓ N more" indicators and the n/p page keys). Shared with
// AskModalDemo.
export const LONG_OPTIONS = [
'None of these',
'GitHub Issues',
'Linear',
'Jira',
'GitLab',
'Gitea',
'Shortcut',
'Sentry',
'Rollbar',
'Bugsnag',
'Honeybadger',
'Raygun',
'Zendesk',
'Freshdesk',
'Freshservice',
'Front',
'Gorgias',
'Kustomer',
'Dixa',
'Plain',
'pganalyze',
'Snyk',
'SonarQube',
'Semgrep',
'Rapid7 InsightVM',
'Featurebase',
'Frill',
'Aha',
'UserVoice',
'Productboard',
'Canny',
'AskNicely',
'Retently',
'Appfigures',
'AppFollow',
'Judge.me',
'Google Search Console',
].map((label) => ({ label, value: label.toLowerCase().replace(/\s+/g, '-') }));

export const InputDemo = () => {
const [step, setStep] = useState<DemoStep>(DemoStep.Single);
const [results, setResults] = useState<string[]>([]);
Expand Down Expand Up @@ -60,6 +105,30 @@ export const InputDemo = () => {
onSelect={(values) => {
const arr = Array.isArray(values) ? values : [values];
setResults((prev) => [...prev, `Multi: ${arr.join(', ')}`]);
setStep(DemoStep.MultiLong);
}}
/>
</Box>
);
}

if (step === DemoStep.MultiLong) {
return (
<Box flexDirection="column">
<Text bold color={Colors.accent}>
Input Demo — Multi Select (long, scrolls)
</Text>
<Box height={1} />
<PickerMenu
message="Which of these do you use? (↑/↓ to scroll)"
mode="multi"
options={LONG_OPTIONS}
onSelect={(values) => {
const arr = Array.isArray(values) ? values : [values];
setResults((prev) => [
...prev,
`Multi (long): ${arr.length} picked`,
]);
setStep(DemoStep.Confirm);
}}
/>
Expand Down
13 changes: 11 additions & 2 deletions src/ui/tui/primitives/GroupedPickerMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ const CHROME_OVERHEAD = 10;
* single border top/bottom (2) + button text (1).
*/
const MENU_CHROME = 6;
/**
* Max visual rows the list renders regardless of terminal height, so a tall
* terminal doesn't turn the menu into a full-screen wall. Mirrors
* PickerMenu's MAX_LIST_ROWS.
*/
const MAX_LIST_ROWS = 12;

/** Count the visual rows occupied by rows[start..end), accounting for header margins. */
function countVisualRows(rows: Row[], start: number, end: number): number {
Expand Down Expand Up @@ -166,7 +172,10 @@ export const GroupedPickerMenu = ({
const focusedRowIdx = selectableIndices[focusedSelectable] ?? 0;

// Viewport budget: how many visual rows can be shown
const viewportBudget = Math.max(5, termRows - CHROME_OVERHEAD - MENU_CHROME);
const viewportBudget = Math.max(
5,
Math.min(termRows - CHROME_OVERHEAD - MENU_CHROME, MAX_LIST_ROWS),
);
const totalVisual = countVisualRows(rows, 0, rows.length);
const needsScroll = totalVisual > viewportBudget;
const effectiveBudget = needsScroll ? viewportBudget - 2 : viewportBudget;
Expand Down Expand Up @@ -267,7 +276,7 @@ export const GroupedPickerMenu = ({
return (
<Box flexDirection="column">
<PromptLabel message={message} />
<Box flexDirection="column" marginTop={1} marginLeft={2}>
<Box flexDirection="column" marginTop={message ? 1 : 0} marginLeft={2}>
{needsScroll && (
<Text dimColor>
{hiddenAbove > 0 ? `\u2191 ${hiddenAbove} more` : ' '}
Expand Down
Loading
Loading