Skip to content

Commit e93193e

Browse files
Polish TUI layouts to match reference style
1 parent 95d842f commit e93193e

11 files changed

Lines changed: 325 additions & 103 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,14 @@ The TUI runs in the terminal alternate screen, so exiting returns to the origina
470470

471471
Setupr TUIs share the same terminal-native style: blue uppercase panel titles, thin blue borders, yellow focused borders/actions, green success states, yellow warnings/current work, and red failures. Interactive inputs stay anchored at the bottom of their panel, wrap within the box, and scroll once long input reaches the panel's line cap. `setupr clean` opens a safety review first; type `CLEAN` to delete reviewed targets, or use `--force` only when you intentionally want Setupr to skip the review prompt.
472472

473+
The visual grammar is shared, but each command uses a command-specific board:
474+
475+
- `setupr` and `setupr status --tui` use summary metric cards plus project state, history, processes, env, security, and next-action panels.
476+
- `setupr setup` uses setup progress, project/dependency/env/service cards, a terminal diary, a bottom input, and a right rail for port map, key dependencies, and notices.
477+
- `setupr chat` uses a large conversation panel with a bottom input plus a right rail for the active plan and session context.
478+
- `setupr start` uses managed processes, live logs, current process state, restart policy, and crash info.
479+
- `setupr doctor`, `setupr update`, `setupr clean`, `setupr env`, and `setupr auth` keep their primary action input at the bottom and use side panels for diagnostics, risks, explanations, warnings, provider status, or secure storage state.
480+
473481
## Requirements
474482

475483
- Node.js >= 18.0.0

src/tui/components/Panel.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export function Panel({
4848
>
4949
<Box width="100%" flexShrink={0} justifyContent="space-between">
5050
<Text color={titleColor} bold>
51-
{focused ? "▸ " : ancestor ? "◆ " : " "}
5251
{title.toUpperCase()}
5352
</Text>
5453
{subtitle && <Text color={colors.textDim} wrap="truncate">{subtitle}</Text>}

src/tui/components/TuiFrame.tsx

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ interface TuiHeaderProps {
1515

1616
export function TuiHeader({ command, title, cwd, stack, status, statusColor, right, width }: TuiHeaderProps) {
1717
const compact = width < 118;
18+
const pathText = cwd ? shortPath(cwd, compact ? 18 : 28) : "";
1819
const leftText = compact
1920
? `${icons.diamond} ${command}${title ? ` · ${title}` : ""}`
20-
: `${icons.diamond} ${command}${title ? ` ${title}` : ""}${stack ? ` Stack: ${stack}` : ""}${cwd ? ` ${shortPath(cwd, Math.max(12, width - 82))}` : ""}`;
21+
: `${icons.diamond} ${command}${title ? ` ${title}` : ""}${stack ? ` Stack: ${stack}` : ""}${pathText ? ` ${pathText}` : ""}`;
2122
const rightText = right || status || "";
2223
const rightWidth = rightText ? Math.min(width - 8, Math.max(8, rightText.length + 2)) : 0;
2324
const leftWidth = Math.max(8, width - rightWidth);
@@ -51,7 +52,7 @@ export function TuiFooter({ width, left, right }: TuiFooterProps) {
5152
return (
5253
<Box width="100%" height={1} justifyContent="space-between">
5354
<Box minWidth={0} flexShrink={1}>
54-
<Text color={colors.textDim} wrap="truncate">{highlightShortcuts(leftText)}</Text>
55+
<Text color={colors.textDim} wrap="truncate">{leftText}</Text>
5556
</Box>
5657
{right && (
5758
<Box flexShrink={0} marginLeft={1}>
@@ -116,16 +117,3 @@ export function formatAge(timestamp: number): string {
116117
if (hours < 24) return `${hours}h`;
117118
return `${Math.floor(hours / 24)}d`;
118119
}
119-
120-
function highlightShortcuts(text: string) {
121-
const shortcutPattern = /^(Ctrl\+[A-Za-z]|Tab|Esc|Enter|q|\/\/\/|\/|Click)$/;
122-
const parts = text.split(/(Ctrl\+[A-Za-z]|Tab|Esc|Enter|\bq\b|\/\/\/|\/|Click)/g);
123-
return (
124-
<>
125-
{parts.map((part, index) => {
126-
const isKey = shortcutPattern.test(part);
127-
return <Text key={`${part}-${index}`} color={isKey ? colors.accent : colors.textDim} bold={isKey}>{part}</Text>;
128-
})}
129-
</>
130-
);
131-
}

src/tui/layouts/AuthLayout.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -157,30 +157,23 @@ function StoragePanel() {
157157

158158
function ProviderRow({ row, compact = false }: { row: { provider: AIProvider; label: string; key?: string; source: string | null; configured: boolean }; compact?: boolean }) {
159159
const config = PROVIDERS[row.provider];
160-
const source = row.source ? ` ${row.source}` : "";
160+
const envLabel = config.envKey.replace("_API_KEY", "");
161+
const keyLabel = row.key ? maskApiKey(row.key) : "missing";
161162
if (compact) {
162163
return (
163164
<Text color={row.configured ? colors.success : colors.textDim} wrap="truncate">
164-
{row.configured ? "✓" : "○"} {row.label} · {row.key ? maskApiKey(row.key) : "missing"}{source}
165+
{row.configured ? "✓" : "○"} {row.label} · {keyLabel}{row.source ? ` · ${row.source}` : ""}
165166
</Text>
166167
);
167168
}
168169

169170
return (
170-
<Box justifyContent="space-between" width="100%" minWidth={0}>
171-
<Box flexShrink={1} minWidth={0} marginRight={1}>
172-
<Text color={row.configured ? colors.success : colors.textDim} wrap="truncate">
173-
{row.configured ? "✓" : "○"} {row.label}
174-
</Text>
175-
<Text color={colors.textDim} wrap="truncate"> {config.envKey.replace("_API_KEY", "")}</Text>
176-
</Box>
177-
<Box flexShrink={0}>
178-
<Text color={row.configured ? colors.value : colors.textDim}>
179-
{row.key ? maskApiKey(row.key) : "missing"}
180-
</Text>
181-
<Text color={colors.textDim}> {row.source || ""}</Text>
182-
</Box>
183-
</Box>
171+
<Text wrap="truncate">
172+
<Text color={row.configured ? colors.success : colors.textDim}>{fitCell(`${row.configured ? "✓" : "○"} ${row.label}`, 16)}</Text>
173+
<Text color={colors.textDim}>{fitCell(envLabel, 16)}</Text>
174+
<Text color={row.configured ? colors.value : colors.textDim}>{fitCell(keyLabel, 14)}</Text>
175+
<Text color={colors.textDim}>{row.source || ""}</Text>
176+
</Text>
184177
);
185178
}
186179

@@ -234,7 +227,7 @@ function buildAuthLayout(width: number, height: number): AuthLayoutGeometry {
234227
};
235228
}
236229

237-
const [providersWidth, modelWidth, actionsWidth] = distributeWidths(width, [0.45, 0.32, 0.23], [34, 30, 24]);
230+
const [providersWidth, modelWidth, actionsWidth] = distributeWidths(width, [0.28, 0.48, 0.24], [30, 40, 28]);
238231
return {
239232
width,
240233
height,
@@ -292,3 +285,9 @@ function fitWidths(total: number, count: number): number[] {
292285
function clamp(value: number, min: number, max: number): number {
293286
return Math.max(min, Math.min(max, value));
294287
}
288+
289+
function fitCell(value: string, width: number): string {
290+
if (value.length === width) return value;
291+
if (value.length > width) return `${value.slice(0, Math.max(0, width - 1))}…`;
292+
return value.padEnd(width, " ");
293+
}

src/tui/layouts/CleanLayout.tsx

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -152,25 +152,30 @@ export function CleanLayout({ cwd, mode, force = false }: CleanLayoutProps) {
152152
}
153153

154154
function WideClean(props: CleanViewProps) {
155+
const panelHeight = Math.max(6, props.layout.bodyHeight - props.layout.inputHeight);
155156
return (
156-
<Box flexDirection="row" width={props.layout.width} height={props.layout.bodyHeight}>
157-
<Panel title="Targets" focusState={props.focus("targets")} width={props.layout.targetsWidth} height="100%">
158-
<TargetsPanel {...props} />
159-
</Panel>
160-
<Panel title="Safety Review" focusState={props.focus("review")} width={props.layout.reviewWidth} height="100%">
161-
<SafetyPanel {...props} />
162-
</Panel>
163-
<Panel title="Risk Summary" focusState={props.focus("risk")} width={props.layout.riskWidth} height="100%">
164-
<RiskPanel {...props} />
165-
</Panel>
157+
<Box flexDirection="column" width={props.layout.width} height={props.layout.bodyHeight}>
158+
<Box flexDirection="row" width="100%" height={panelHeight}>
159+
<Panel title="Targets" focusState={props.focus("targets")} width={props.layout.targetsWidth} height="100%">
160+
<TargetsPanel {...props} limit={panelHeight - 3} />
161+
</Panel>
162+
<Panel title="Safety Review" focusState={props.focus("review")} width={props.layout.reviewWidth} height="100%">
163+
<SafetyPanel {...props} />
164+
</Panel>
165+
<Panel title="Risk Summary" focusState={props.focus("risk")} width={props.layout.riskWidth} height="100%">
166+
<RiskPanel {...props} />
167+
</Panel>
168+
</Box>
169+
<CleanCommandStrip {...props} width={props.layout.width} />
166170
</Box>
167171
);
168172
}
169173

170174
function StackedClean(props: CleanViewProps) {
171-
const targetsHeight = Math.max(8, Math.floor(props.layout.bodyHeight * 0.45));
172-
const reviewHeight = Math.max(7, Math.floor(props.layout.bodyHeight * 0.32));
173-
const riskHeight = Math.max(5, props.layout.bodyHeight - targetsHeight - reviewHeight);
175+
const panelHeight = Math.max(8, props.layout.bodyHeight - props.layout.inputHeight);
176+
const targetsHeight = Math.max(8, Math.floor(panelHeight * 0.45));
177+
const reviewHeight = Math.max(7, Math.floor(panelHeight * 0.32));
178+
const riskHeight = Math.max(5, panelHeight - targetsHeight - reviewHeight);
174179
return (
175180
<Box flexDirection="column" width={props.layout.width} height={props.layout.bodyHeight}>
176181
<Panel title="Targets" focusState={props.focus("targets")} width="100%" height={targetsHeight}>
@@ -182,6 +187,7 @@ function StackedClean(props: CleanViewProps) {
182187
<Panel title="Risk Summary" focusState={props.focus("risk")} width="100%" height={riskHeight}>
183188
<RiskPanel {...props} compact />
184189
</Panel>
190+
<CleanCommandStrip {...props} width={props.layout.width} />
185191
</Box>
186192
);
187193
}
@@ -207,7 +213,7 @@ function TargetsPanel({ targets, phase, limit = 12 }: CleanViewProps & { limit?:
207213
);
208214
}
209215

210-
function SafetyPanel({ layout, mode, phase, message, targets, inputActive, inputBounds, focus, onConfirm }: CleanViewProps) {
216+
function SafetyPanel({ mode, phase, message, targets }: CleanViewProps) {
211217
const willDelete = targets.filter((target) => target.status === "pending" || target.status === "removing").length;
212218
return (
213219
<Box flexDirection="column" flexGrow={1} minHeight={0}>
@@ -219,17 +225,30 @@ function SafetyPanel({ layout, mode, phase, message, targets, inputActive, input
219225
<Text color={phase === "blocked" ? colors.warning : phase === "done" ? colors.success : colors.text} wrap="truncate">{message}</Text>
220226
<KVRow label="Pending targets" value={willDelete} color={willDelete > 0 ? colors.warning : colors.success} />
221227
</Box>
222-
{(phase === "review" || phase === "blocked") && (
223-
<ChatInput
224-
active={inputActive}
225-
focusState={focus("input")}
226-
onSubmit={onConfirm}
227-
placeholder='Type CLEAN to confirm deletion...'
228-
width={Math.max(12, layout.reviewWidth - 4)}
229-
maxLines={layout.inputMaxLines}
230-
scrollBounds={inputBounds}
231-
/>
232-
)}
228+
</Box>
229+
);
230+
}
231+
232+
function CleanCommandStrip({ layout, phase, inputActive, inputBounds, focus, onConfirm, width }: CleanViewProps & { width: number }) {
233+
const disabled = phase !== "review" && phase !== "blocked";
234+
const placeholder = phase === "blocked"
235+
? 'Type CLEAN to confirm, or q to quit...'
236+
: phase === "done"
237+
? "Clean finished. Ask or press q to quit..."
238+
: "Type CLEAN to confirm deletion...";
239+
return (
240+
<Box width="100%" height={layout.inputHeight} justifyContent="center" alignItems="center">
241+
<ChatInput
242+
active={inputActive}
243+
focusState={focus("input")}
244+
onSubmit={onConfirm}
245+
placeholder={placeholder}
246+
width={Math.max(12, width - 2)}
247+
maxLines={layout.inputMaxLines}
248+
scrollBounds={inputBounds}
249+
disabled={disabled}
250+
disabledText={placeholder}
251+
/>
233252
</Box>
234253
);
235254
}
@@ -257,15 +276,21 @@ export function buildCleanLayout(width: number, height: number): CleanLayoutGeom
257276
const reviewWidth = stacked ? width : width - targetsWidth - riskWidth;
258277
const inputMaxLines = Math.max(1, Math.min(4, Math.floor(bodyHeight / 6)));
259278
const inputHeight = inputMaxLines + 2;
260-
const inputBounds = { x: stacked ? 3 : targetsWidth + 3, y: Math.max(4, height - inputHeight - 1), width: Math.max(8, reviewWidth - 6), height: inputHeight };
279+
const inputBounds = {
280+
x: stacked ? 3 : 3,
281+
y: Math.max(4, height - inputHeight - 1),
282+
width: Math.max(8, stacked ? width - 6 : width - 6),
283+
height: inputHeight,
284+
};
261285
return { width, height, stacked, bodyHeight, targetsWidth, reviewWidth, riskWidth, inputMaxLines, inputHeight, inputBounds };
262286
}
263287

264288
export function buildCleanFocusItems(layout: CleanLayoutGeometry): FocusItem[] {
265289
if (layout.stacked) {
266-
const targetsHeight = Math.max(8, Math.floor(layout.bodyHeight * 0.45));
267-
const reviewHeight = Math.max(7, Math.floor(layout.bodyHeight * 0.32));
268-
const riskHeight = Math.max(5, layout.bodyHeight - targetsHeight - reviewHeight);
290+
const panelHeight = Math.max(8, layout.bodyHeight - layout.inputHeight);
291+
const targetsHeight = Math.max(8, Math.floor(panelHeight * 0.45));
292+
const reviewHeight = Math.max(7, Math.floor(panelHeight * 0.32));
293+
const riskHeight = Math.max(5, panelHeight - targetsHeight - reviewHeight);
269294
return [
270295
{ id: "targets", row: 0, column: 0, bounds: { x: 1, y: 2, width: layout.width, height: targetsHeight } },
271296
{ id: "review", row: 1, column: 0, redirectTo: "input", bounds: { x: 1, y: 2 + targetsHeight, width: layout.width, height: reviewHeight } },
@@ -274,10 +299,10 @@ export function buildCleanFocusItems(layout: CleanLayoutGeometry): FocusItem[] {
274299
];
275300
}
276301
return [
277-
{ id: "targets", row: 0, column: 0, bounds: { x: 1, y: 2, width: layout.targetsWidth, height: layout.bodyHeight } },
278-
{ id: "review", row: 0, column: 1, redirectTo: "input", bounds: { x: layout.targetsWidth + 1, y: 2, width: layout.reviewWidth, height: layout.bodyHeight } },
302+
{ id: "targets", row: 0, column: 0, bounds: { x: 1, y: 2, width: layout.targetsWidth, height: layout.bodyHeight - layout.inputHeight } },
303+
{ id: "review", row: 0, column: 1, redirectTo: "input", bounds: { x: layout.targetsWidth + 1, y: 2, width: layout.reviewWidth, height: layout.bodyHeight - layout.inputHeight } },
279304
{ id: "input", row: 1, column: 1, parentIds: ["review"], bounds: layout.inputBounds },
280-
{ id: "risk", row: 0, column: 2, bounds: { x: layout.targetsWidth + layout.reviewWidth + 1, y: 2, width: layout.riskWidth, height: layout.bodyHeight } },
305+
{ id: "risk", row: 0, column: 2, bounds: { x: layout.targetsWidth + layout.reviewWidth + 1, y: 2, width: layout.riskWidth, height: layout.bodyHeight - layout.inputHeight } },
281306
];
282307
}
283308

src/tui/layouts/DashboardLayout.tsx

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -427,33 +427,29 @@ function ActionsHistoryPanel({ status, limit }: { status: DashboardStatus; limit
427427
...status.history.slice(-Math.max(0, Math.floor(limit / 2))).map((event) => ({
428428
left: formatTime(event.timestamp),
429429
middle: event.type,
430-
right: event.message || "complete",
430+
status: event.type.includes("error") ? "error" : event.type.includes("warning") ? "warn" : "complete",
431+
detail: event.message || "finished",
431432
color: event.type.includes("error") ? colors.error : event.type.includes("warning") ? colors.warning : colors.success,
432433
})),
433434
...preferredCommands(status).slice(0, Math.max(2, limit - Math.floor(limit / 2))).map((command) => ({
434-
left: "ACTION",
435+
left: "shortcut",
435436
middle: command.name,
436-
right: command.summary,
437+
status: "ready",
438+
detail: command.summary,
437439
color: colors.primary,
438440
})),
439441
].slice(0, Math.max(1, limit));
440442

441443
return (
442444
<Box flexDirection="column">
443-
<Box justifyContent="space-between">
444-
<Text color={colors.heading}>TIME / ACTION</Text>
445-
<Text color={colors.heading}>STATUS</Text>
446-
</Box>
445+
<Text color={colors.heading}>TIME ACTION STATUS DETAIL</Text>
447446
{rows.length === 0 ? <Text color={colors.textDim}>No local history yet.</Text> : rows.map((row, index) => (
448-
<Box key={`${row.left}-${row.middle}-${index}`} justifyContent="space-between" minWidth={0}>
449-
<Box flexShrink={1} minWidth={0}>
450-
<Text color={colors.textDim}>{row.left} </Text>
451-
<Text color={colors.text} wrap="truncate">{row.middle}</Text>
452-
</Box>
453-
<Box flexShrink={1} minWidth={0}>
454-
<Text color={row.color} wrap="truncate">{row.right}</Text>
455-
</Box>
456-
</Box>
447+
<Text key={`${row.left}-${row.middle}-${index}`} wrap="truncate">
448+
<Text color={colors.textDim}>{fitCell(row.left, 8)} </Text>
449+
<Text color={row.color}>{fitCell(row.middle, 20)} </Text>
450+
<Text color={row.color}>{fitCell(row.status, 9)} </Text>
451+
<Text color={colors.textDim}>{row.detail}</Text>
452+
</Text>
457453
))}
458454
</Box>
459455
);
@@ -478,13 +474,11 @@ function ProjectStatePanel({ status, limit }: { status: DashboardStatus; limit:
478474
return (
479475
<Box flexDirection="column">
480476
{rows.slice(-Math.max(1, limit)).map((event, index) => (
481-
<Box key={`${event.timestamp}-${event.type}-${index}`} justifyContent="space-between" minWidth={0}>
482-
<Box minWidth={0} flexShrink={1}>
483-
<Text color={colors.textDim}>{formatTime(event.timestamp)} </Text>
484-
<Text color={colors.text} wrap="truncate">{event.message || event.type}</Text>
485-
</Box>
477+
<Text key={`${event.timestamp}-${event.type}-${index}`} wrap="truncate">
478+
<Text color={colors.textDim}>{fitCell(formatTime(event.timestamp), 8)} </Text>
479+
<Text color={colors.text}>{fitCell(event.message || event.type, 42)} </Text>
486480
<Text color={event.type.includes("error") ? colors.error : colors.success}>complete</Text>
487-
</Box>
481+
</Text>
488482
))}
489483
</Box>
490484
);
@@ -635,6 +629,12 @@ function formatTime(timestamp: number): string {
635629
return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
636630
}
637631

632+
function fitCell(value: string, width: number): string {
633+
if (value.length === width) return value;
634+
if (value.length > width) return `${value.slice(0, Math.max(0, width - 1))}…`;
635+
return value.padEnd(width, " ");
636+
}
637+
638638
function distributeWidths(total: number, weights: number[], mins: number[]): number[] {
639639
const minTotal = mins.reduce((sum, item) => sum + item, 0);
640640
if (total <= minTotal) return fitWidths(total, mins.length);

0 commit comments

Comments
 (0)