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
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
2 changes: 2 additions & 0 deletions source/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export const EMPTY_CONTENT_MARKER = '[file is empty]';
export const PATH_LENGTH_NARROW_TERMINAL = 30;
export const PATH_LENGTH_NORMAL_TERMINAL = 60;
export const TABLE_COLUMN_MIN_WIDTH = 10;
export const WIZARD_ROW_CHROME_CHARS = 10;
export const MIN_PATH_BUDGET_CHARS = 10;

// === TOKEN THRESHOLDS (percentages - useChatHandler) ===
export const TOKEN_THRESHOLD_WARNING_PERCENT = 80;
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
32 changes: 28 additions & 4 deletions source/wizards/steps/location-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {useState} from 'react';
import {StyledSelectInput} from '@/components/ui/styled-select-input';
import {getColors} from '@/config';
import {getConfigPath} from '@/config/paths';
import {MIN_PATH_BUDGET_CHARS, WIZARD_ROW_CHROME_CHARS} from '@/constants';
import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
import {homeRelative, truncateMiddle} from '@/utils/path';

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

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

export function LocationStep({
Expand All @@ -28,7 +31,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 +54,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 +106,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 +139,25 @@ 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(
MIN_PATH_BUDGET_CHARS,
boxWidth - WIZARD_ROW_CHROME_CHARS,
);
return (
<Box flexDirection="column">
<Text color={color} wrap="truncate-end">
{label}
</Text>
<Box marginLeft={2}>
<Text color={colors.secondary} wrap="truncate-end">
{truncateMiddle(path, pathBudget)}
</Text>
</Box>
</Box>
);
}}
/>
{!isNarrow && (
<Box marginTop={1}>
Expand Down
Loading