Skip to content
Merged
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
12 changes: 6 additions & 6 deletions memory/SPEC.md

Large diffs are not rendered by default.

127 changes: 117 additions & 10 deletions src/.pi/__tests__/chrome.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
import { visibleWidth } from '@earendil-works/pi-tui';
import { describe, expect, it } from 'vitest';

import type { WorkspaceSessionReadyState } from '../../session/workspace-session-coordinator.js';
import {
import { BrunchStartupHeader } from '../components/chrome-header.js';
import chromeExtension, {
chromeStateForWorkspace,
projectBrunchChromeFooterLines,
renderBrunchChrome,
Expand Down Expand Up @@ -39,12 +41,14 @@ describe('Brunch chrome projection', () => {
session: { id: 'session-1', label: 'Interview #1' },
phase: 'elicitation' as const,
chatMode: 'responding-to-elicitation' as const,
webSidecarUrl: 'http://127.0.0.1:49152/spec/1',
};

expect(projectBrunchChromeFooterLines(state)).toEqual([
'/tmp/project no model',
'no branch ctx ──────────── ?% ?/0',
'proj: project | spec: Spec One | mode: not reported | strategy: not reported | lens: not reported',
'web-ui: http://127.0.0.1:49152/spec/1',
'',
]);
});
Expand Down Expand Up @@ -117,15 +121,7 @@ describe('Brunch chrome projection', () => {

it('renders Brunch chrome through one wrapper over Pi UI calls', async () => {
const calls: FakeUiCall[] = [];
const ui: FakeExtensionUi = {
setHeader: (...args: unknown[]) => calls.push({ method: 'setHeader', args }),
setFooter: (...args: unknown[]) => calls.push({ method: 'setFooter', args }),
setStatus: (...args: unknown[]) => calls.push({ method: 'setStatus', args }),
setWidget: (...args: unknown[]) => calls.push({ method: 'setWidget', args }),
setWorkingIndicator: (_options) => {},
setTitle: (...args: unknown[]) => calls.push({ method: 'setTitle', args }),
notify: (_message: string, _type?: 'info' | 'warning' | 'error') => {},
};
const ui = fakeChromeUi(calls);

renderBrunchChrome(ui, {
cwd: '/tmp/project',
Expand All @@ -140,6 +136,98 @@ describe('Brunch chrome projection', () => {
expect(calls.some((call) => call.method === 'setStatus')).toBe(false);
expect(calls.find((call) => call.method === 'setTitle')?.args).toEqual(['brunch — project · Spec One']);
});

it('installs the full startup header only when chrome state requests it', async () => {
const calls: FakeUiCall[] = [];

renderBrunchChrome(fakeChromeUi(calls), {
cwd: '/tmp/project',
project: { name: 'Project One', slug: 'project-one' },
spec: { id: 1, title: 'Spec One' },
session: { id: 'session-1', label: 'Spec One — session 1' },
phase: 'elicitation',
chatMode: 'responding-to-elicitation',
webSidecarUrl: 'http://127.0.0.1:49152/spec/1',
startupHeader: { decision: 'newSession' },
});

const headerFactory = calls.find((call) => call.method === 'setHeader')?.args[0];
expect(headerFactory).toEqual(expect.any(Function));

const component = (headerFactory as (tui: unknown, theme: FakeTheme) => BrunchStartupHeader)(
undefined,
fakeTheme,
);
const collapsedLines = component.render(120);
expect(collapsedLines.slice(0, 6)).toEqual(['', '', '', '', '', '']);
expect(collapsedLines.join('\n')).toContain('brunch v0.1.0');
expect(collapsedLines.join('\n')).toContain('/brunch switch');
expect(collapsedLines.join('\n')).toContain('web-ui: http://127.0.0.1:49152/spec/1');
expect(collapsedLines.join('\n')).not.toContain('Press ctrl+o');
expect(collapsedLines.join('\n')).not.toContain('Spec One — session 1');
component.setExpanded(true);
expect(component.render(120).join('\n')).toContain('Current session: Spec One — session 1');
expect(component.render(120).join('\n')).toContain('web-ui: http://127.0.0.1:49152/spec/1');
expect(component.render(120).join('\n')).toContain('Graph capture');

const resumedCalls: FakeUiCall[] = [];
renderBrunchChrome(fakeChromeUi(resumedCalls), {
cwd: '/tmp/project',
spec: { id: 1, title: 'Spec One' },
session: { id: 'session-1' },
phase: 'elicitation',
chatMode: 'responding-to-elicitation',
});
expect(resumedCalls.some((call) => call.method === 'setHeader')).toBe(false);
});

it('installs dev fallback header through the src/.pi extension entrypoint', async () => {
const calls: FakeUiCall[] = [];
const sessionStart: Array<(event: unknown, ctx: { ui: FakeExtensionUi }) => Promise<void> | void> = [];

chromeExtension({
on: (event: string, handler: never) => {
if (event === 'session_start') sessionStart.push(handler);
},
} as never);

expect(sessionStart).toHaveLength(1);
await sessionStart[0]!({}, { ui: fakeChromeUi(calls) });

expect(calls.map((call) => call.method)).toEqual(['setFooter', 'setHeader', 'setTitle']);
});

it('keeps startup header text width-safe and newline-safe', () => {
const component = new BrunchStartupHeader(
{
project: 'Project\nOne',
spec: 'Spec\rOne',
session: 'Session\tOne',
sidecarUrl: 'http://127.0.0.1:49152/spec/1\nignored',
},
fakeTheme,
);

expect(component.render(36).every((line) => !/[\r\n\t]/.test(line))).toBe(true);
expect(component.render(36).every((line) => visibleWidth(line) <= 36)).toBe(true);
component.setExpanded(true);
expect(component.render(36).every((line) => !/[\r\n\t]/.test(line))).toBe(true);
});

it('does not project the active web sidecar URL into an upper widget', async () => {
const calls: FakeUiCall[] = [];

renderBrunchChrome(fakeChromeUi(calls), {
cwd: '/tmp/project',
spec: { id: 1, title: 'Spec One' },
session: { id: 'session-1' },
phase: 'elicitation',
chatMode: 'responding-to-elicitation',
webSidecarUrl: 'http://127.0.0.1:49152/spec/1\nignored',
});

expect(calls.some((call) => call.method === 'setWidget')).toBe(false);
});
});

function readyWorkspace(cwd: string, sessionId: string, sessionName?: string): WorkspaceSessionReadyState {
Expand Down Expand Up @@ -168,6 +256,25 @@ interface FakeUiCall {
args: unknown[];
}

function fakeChromeUi(calls: FakeUiCall[]): FakeExtensionUi {
return {
setHeader: (...args: unknown[]) => calls.push({ method: 'setHeader', args }),
setFooter: (...args: unknown[]) => calls.push({ method: 'setFooter', args }),
setStatus: (...args: unknown[]) => calls.push({ method: 'setStatus', args }),
setWidget: (...args: unknown[]) => calls.push({ method: 'setWidget', args }),
setWorkingIndicator: (_options) => {},
setTitle: (...args: unknown[]) => calls.push({ method: 'setTitle', args }),
notify: (_message: string, _type?: 'info' | 'warning' | 'error') => {},
};
}

const fakeTheme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
};

type FakeTheme = typeof fakeTheme;

type FakeExtensionUi = Pick<
ExtensionUIContext,
'setFooter' | 'setHeader' | 'setStatus' | 'setWidget' | 'setWorkingIndicator' | 'setTitle' | 'notify'
Expand Down
8 changes: 8 additions & 0 deletions src/.pi/__tests__/extension-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe('Brunch explicit Pi extension registry', () => {
}
});

it('keeps the src/.pi chrome entrypoint activated for direct Pi iteration', async () => {
const settings = JSON.parse(await readFile(join(projectRoot(), 'src/.pi/settings.json'), 'utf8')) as {
extensions?: unknown;
};

expect(settings.extensions).toContain('extensions/chrome/index.ts');
});

it('registers product extensions from the shell in explicit order', async () => {
const recording = createRecordingExtensionApi();

Expand Down
90 changes: 90 additions & 0 deletions src/.pi/__tests__/tui-lab-cycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';

import {
DEMO_MODEL_SEGMENTS,
nextSegmentIndex,
normalizeActiveIndex,
previousSegmentIndex,
renderSegmentTrack,
trackVisibleWidth,
type LabTheme,
} from '../components/tui-lab/index.js';
import { TuiStyleLabComponent } from '../extensions/tui-lab/index.js';

const theme = createTheme();

describe('TUI style lab segment track', () => {
it('renders the active segment as a solid chip and inactive labels as colored text', () => {
const track = renderSegmentTrack(theme, DEMO_MODEL_SEGMENTS, 1);

expect(track).toContain('\x1b[48;5;33m\x1b[30m default ');
expect(track).toContain('\x1b[38;5;34msmol\x1b[39m');
expect(track).toContain('\x1b[38;5;220mslow\x1b[39m');
});

it('accepts arbitrary segment labels and colors', () => {
const track = renderSegmentTrack(
theme,
[
{ label: 'ask', color: 'accent' },
{ label: 'shape', color: 'customMessageLabel' },
{ label: 'lock', color: 'success' },
],
2,
);

expect(track).toContain('ask');
expect(track).toContain('shape');
expect(track).toContain('\x1b[48;5;34m\x1b[30m lock ');
});

it('keeps visible width within the requested maximum', () => {
const track = renderSegmentTrack(theme, DEMO_MODEL_SEGMENTS, 1, 14);

expect(trackVisibleWidth(track)).toBeLessThanOrEqual(14);
});

it('wraps active indexes forward and backward', () => {
expect(normalizeActiveIndex(4, 3)).toBe(1);
expect(normalizeActiveIndex(-1, 3)).toBe(2);
expect(nextSegmentIndex(2, 3)).toBe(0);
expect(previousSegmentIndex(0, 3)).toBe(2);
});
});

describe('TUI style lab cycle demo component', () => {
it('cycles only local demo state and requests no model mutation API', () => {
let closed = false;
const component = new TuiStyleLabComponent(theme, () => {
closed = true;
});

expect(component.render(80).join('\n')).toContain('default');
component.handleInput?.('\x1b[C');
expect(component.render(80).join('\n')).toContain('\x1b[48;5;220m\x1b[30m slow ');
component.handleInput?.('\x1b[D');
expect(component.render(80).join('\n')).toContain('\x1b[48;5;33m\x1b[30m default ');
component.handleInput?.('\x1b');
expect(closed).toBe(true);
});
});

function createTheme(): LabTheme {
const colorCodes: Record<string, string> = {
accent: '\x1b[38;5;33m',
success: '\x1b[38;5;34m',
warning: '\x1b[38;5;220m',
error: '\x1b[38;5;196m',
muted: '\x1b[38;5;244m',
dim: '\x1b[38;5;240m',
text: '\x1b[39m',
customMessageLabel: '\x1b[38;5;99m',
toolTitle: '\x1b[38;5;69m',
syntaxKeyword: '\x1b[38;5;141m',
};
return {
fg: (color, text) => `${colorCodes[color]}${text}\x1b[39m`,
inverse: (text) => `\x1b[7m${text}\x1b[27m`,
getFgAnsi: (color) => colorCodes[color],
};
}
Loading
Loading