Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/loud-plums-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": patch
---

Setup wizard's config location picker now shows the resolved path next to each option instead of a bare label.
4 changes: 2 additions & 2 deletions source/app/components/app-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getGitStatusSummarySync,
} from '@/tools/git/utils';
import {DEVELOPMENT_MODE_LABELS, type DevelopmentMode} from '@/types/core';
import {homeRelative} from '@/utils/path';

/**
* Format a {@link GitStatusSummary} for inline display next to the
Expand Down Expand Up @@ -54,8 +55,7 @@ function BootSummary({
const {colors} = useTheme();
const {isNarrow} = useResponsiveTerminal();
const configPath = getClosestConfigFile('agents.config.json');
const homedir = process.env.HOME || process.env.USERPROFILE || '';
const shortConfig = homedir ? configPath.replace(homedir, '~') : configPath;
const shortConfig = homeRelative(configPath);
const modeLabel = mode ? DEVELOPMENT_MODE_LABELS[mode] : undefined;
const gitStatus = getGitStatusSummarySync();
const gitLabel = gitStatus ? formatBootSummaryGitLabel(gitStatus) : undefined;
Expand Down
46 changes: 26 additions & 20 deletions source/components/ui/styled-select-input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Box, Text} from 'ink';
import SelectInput from 'ink-select-input';
import type {ReactElement} from 'react';
import type {ComponentProps, ReactElement} from 'react';

import {useTheme} from '@/hooks/useTheme';

Expand All @@ -25,25 +25,37 @@ interface Item<V> {
* and selected-label colour are a hardcoded `blue` that all but disappears
* against a dark terminal background.
*/
interface StyledSelectInputProps<V> {
items?: Array<Item<V>>;
interface StyledSelectInputProps<V, I extends Item<V> = Item<V>> {
items?: Array<I>;
isFocused?: boolean;
initialIndex?: number;
limit?: number;
onSelect?: (item: Item<V>) => void;
onHighlight?: (item: Item<V>) => void;
itemComponent?: (props: {
isSelected?: boolean;
label: string;
}) => ReactElement;
onSelect?: (item: I) => void;
onHighlight?: (item: I) => void;
itemComponent?: (props: I & {isSelected?: boolean}) => ReactElement;
}

export function StyledSelectInput<V>(props: StyledSelectInputProps<V>) {
export function StyledSelectInput<V, I extends Item<V> = Item<V>>(
props: StyledSelectInputProps<V, I>,
) {
const {colors} = useTheme();

const itemComponent =
props.itemComponent ??
(({isSelected, label}) => (
<Text
color={isSelected ? colors.primary : colors.text}
wrap="truncate-end"
>
{label}
</Text>
));

// ink-select-input's runtime spreads the whole item into these; its own
// types just don't say so.
return (
<SelectInput
{...props}
{...(props as unknown as ComponentProps<typeof SelectInput>)}
// Fixed-width indicator: Ink trims a trailing space only on rows that
// overflow, which left truncated rows a column left of short ones.
indicatorComponent={({isSelected}) => (
Expand All @@ -56,15 +68,9 @@ export function StyledSelectInput<V>(props: StyledSelectInputProps<V>) {
// Truncate rather than wrap: a long label (a path, a URL) reflowed with
// no hanging indent and the list read as a jumble on narrow terminals.
itemComponent={
props.itemComponent ??
(({isSelected, label}) => (
<Text
color={isSelected ? colors.primary : colors.text}
wrap="truncate-end"
>
{label}
</Text>
))
itemComponent as unknown as ComponentProps<
typeof SelectInput
>['itemComponent']
}
/>
);
Expand Down
44 changes: 44 additions & 0 deletions source/utils/path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import test from 'ava';
import {resolve, sep} from 'node:path';
import {homeRelative, truncateMiddle} from './path.js';

const HOME = resolve('/Users/will');

test('homeRelative shortens a path inside home to a tilde form', t => {
const input = resolve('/Users/will/projects/app');
t.is(homeRelative(input, HOME), `~${sep}projects${sep}app`);
});

test('homeRelative returns a bare tilde for the home directory itself', t => {
t.is(homeRelative(resolve('/Users/will'), HOME), '~');
});

test('homeRelative does not mangle a sibling directory that shares a prefix', t => {
const input = resolve('/Users/willy/projects/app');
t.is(homeRelative(input, HOME), input);
});

test('homeRelative leaves unrelated paths untouched', t => {
const input = resolve('/etc/config');
t.is(homeRelative(input, HOME), input);
});

test('homeRelative leaves paths untouched when home is the filesystem root', t => {
const root = resolve('/');
const child = resolve('/foo');
t.is(homeRelative(child, root), child);
t.is(homeRelative(root, root), root);
});

test('truncateMiddle leaves short strings untouched', t => {
t.is(truncateMiddle('/short/path', 40), '/short/path');
});

test('truncateMiddle keeps both the root and the leaf segment', t => {
const long = '/Users/will/projects/some-really-long-monorepo-name/src/index.ts';
const result = truncateMiddle(long, 30);
t.is(result.length, 30);
t.true(result.startsWith('/Users/wi'));
t.true(result.endsWith('index.ts'));
t.true(result.includes('...'));
});
38 changes: 38 additions & 0 deletions source/utils/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {homedir} from 'node:os';
import {resolve, sep} from 'node:path';

export function homeRelative(path: string, home: string = homedir()): string {
const resolved = resolve(path);
const resolvedHome = resolve(home);

if (resolvedHome === sep || /^[A-Za-z]:\\$/.test(resolvedHome)) {
return resolved;
}

if (resolved === resolvedHome) {
return '~';
}

if (resolved.startsWith(resolvedHome + sep)) {
return `~${resolved.slice(resolvedHome.length)}`;
}

return resolved;
}

// Keeps root and leaf visible; truncatePath (useTerminalWidth.tsx) only keeps the tail.
export function truncateMiddle(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str;
}

const ellipsis = '...';
if (maxLength <= ellipsis.length) {
return str.slice(0, Math.max(0, maxLength));
}

const keepStart = Math.ceil((maxLength - ellipsis.length) / 2);
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2);

return str.slice(0, keepStart) + ellipsis + str.slice(str.length - keepEnd);
}
71 changes: 71 additions & 0 deletions source/wizards/steps/location-step.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import {resolve} from 'node:path';
import test from 'ava';
import {TitledBoxWithPreferences} from '@/components/ui/titled-box';
import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
import {renderWithTheme as render} from '@/test-utils/render-with-theme';
import React from 'react';
import {LocationStep} from './location-step.js';

// Mirrors base-config-wizard.tsx's real LocationStep box.
function WizardBox({
projectDir,
onComplete,
}: {
projectDir: string;
onComplete: () => void;
}) {
const {boxWidth} = useResponsiveTerminal();
return (
<TitledBoxWithPreferences
title="Setup"
width={boxWidth}
borderColor="blue"
paddingX={2}
paddingY={1}
flexDirection="column"
>
<LocationStep onComplete={onComplete} projectDir={projectDir} />
</TitledBoxWithPreferences>
);
}

// ============================================================================
// Tests for LocationStep Component Rendering
// ============================================================================
Expand Down Expand Up @@ -37,6 +63,51 @@ test('LocationStep shows global config option', t => {
t.regex(output!, /Global user config/);
});

test('LocationStep shows the resolved project path next to the option', t => {
const {lastFrame} = render(
<LocationStep onComplete={() => {}} projectDir="/test/project" />,
);

const output = lastFrame();
t.truthy(output);
const lines = output!.split('\n');
const stemIndex = lines.findIndex(line =>
line.includes('Current project directory'),
);
t.true(stemIndex !== -1, 'expected to find the project directory stem');
t.is(lines[stemIndex + 1]?.trim(), resolve('/test/project'));
});

test('LocationStep does not clip the leaf directory inside the real wizard box', t => {
const originalColumns = process.stdout.columns;
try {
for (const columns of [60, 80, 120]) {
Object.defineProperty(process.stdout, 'columns', {
value: columns,
configurable: true,
});

const {lastFrame} = render(
<WizardBox
onComplete={() => {}}
projectDir="/Users/will/Documents/GitHub/some-org/some-really-long-monorepo-name/packages/leaf-dir"
/>,
);

const output = lastFrame();
t.true(
output!.includes('leaf-dir'),
`at ${columns} cols, expected leaf directory to stay visible, got: ${output}`,
);
}
} finally {
Object.defineProperty(process.stdout, 'columns', {
value: originalColumns,
configurable: true,
});
}
});

test('LocationStep shows tip about config types', t => {
const {lastFrame} = render(
<LocationStep onComplete={() => {}} projectDir="/test/project" />,
Expand Down
27 changes: 23 additions & 4 deletions source/wizards/steps/location-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {StyledSelectInput} from '@/components/ui/styled-select-input';
import {getColors} from '@/config';
import {getConfigPath} from '@/config/paths';
import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
import {homeRelative, truncateMiddle} from '@/utils/path';

export type ConfigLocation = 'project' | 'global';

Expand All @@ -19,6 +20,7 @@ interface LocationStepProps {
interface LocationOption {
label: string;
value: ConfigLocation;
path: string;
}

export function LocationStep({
Expand All @@ -28,7 +30,7 @@ export function LocationStep({
configFileName = 'agents.config.json',
}: LocationStepProps) {
const colors = getColors();
const {isNarrow, truncatePath} = useResponsiveTerminal();
const {boxWidth, isNarrow} = useResponsiveTerminal();
const projectPath = join(projectDir, configFileName);
const globalPath = join(getConfigPath(), configFileName);

Expand All @@ -51,12 +53,14 @@ export function LocationStep({

const locationOptions: LocationOption[] = [
{
label: `Global user config`,
label: 'Global user config',
value: 'global',
path: homeRelative(getConfigPath()),
},
{
label: `Current project directory`,
label: 'Current project directory',
value: 'project',
path: homeRelative(projectDir),
},
];

Expand Down Expand Up @@ -101,7 +105,7 @@ export function LocationStep({
Configuration found at:{' '}
</Text>
<Text color={colors.secondary}>
{isNarrow ? truncatePath(existingPath, 40) : existingPath}
{isNarrow ? truncateMiddle(existingPath, 40) : existingPath}
</Text>
</Box>
<StyledSelectInput
Expand Down Expand Up @@ -134,6 +138,21 @@ export function LocationStep({
<StyledSelectInput
items={locationOptions}
onSelect={(item: LocationOption) => handleLocationSelect(item)}
itemComponent={({isSelected, label, path}) => {
const color = isSelected ? colors.primary : colors.text;
const pathBudget = Math.max(10, boxWidth - 10);
return (
<Box flexDirection="column">
<Text color={color} wrap="truncate-end">
{label}
</Text>
<Text color={colors.secondary} wrap="truncate-end">
{' '}
{truncateMiddle(path, pathBudget)}
</Text>
</Box>
);
}}
/>
{!isNarrow && (
<Box marginTop={1}>
Expand Down
Loading