Skip to content

Commit 48b44e1

Browse files
Merge pull request #317 from corbitsdev/cl-5341-make-settings-ui-reflow-on-narrow-terminals
Make settings UI reflow on narrow terminals
2 parents 6824152 + d819098 commit 48b44e1

4 files changed

Lines changed: 401 additions & 110 deletions

File tree

src/tui/components/agent-modal.tsx

Lines changed: 192 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { supportedEfforts, type ReasoningEffort } from "../../provider/reasoning
77
import { PROVIDER_TIERS, type ProviderTier, type TierConfig } from "../../config/settings.js";
88
import { formatTierChain, normalizeTierDefinition } from "../../config/inference-sources.js";
99
import type { AgentProfile } from "../../agent/profiles.js";
10+
import { useTerminalSize } from "../hooks/use-terminal-size.js";
11+
import {
12+
STACK_FORM_COLUMNS,
13+
fitTrailingText,
14+
formContentWidth,
15+
wrapHelpSegments,
16+
} from "./form-reflow.js";
1017

1118
// Effort display: undefined means "no override" (field omitted); "none" is
1219
// OpenAI's explicit disable-reasoning value. Both read as "off".
@@ -249,6 +256,15 @@ export function AgentModal({
249256
unauthedProviders,
250257
onRequestLogin,
251258
}: AgentModalProps): ReactNode {
259+
const { columns } = useTerminalSize();
260+
const stackFields = columns < STACK_FORM_COLUMNS;
261+
const contentWidth = formContentWidth(columns, stackFields);
262+
// Label column widths used in row layout; stacked layout uses full content width for values.
263+
const providerLabelWidth = 16;
264+
const profileLabelWidth = 14;
265+
const valueWidth = stackFields
266+
? contentWidth
267+
: Math.max(8, contentWidth - Math.max(providerLabelWidth, profileLabelWidth) - 1);
252268
const initialProvider = Math.max(
253269
0,
254270
providers.findIndex((p) => p.name === activeProvider),
@@ -773,13 +789,40 @@ export function AgentModal({
773789
setFormError(null);
774790
});
775791

792+
const helpText = ((): string | null => {
793+
switch (step) {
794+
case "provider":
795+
return "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close";
796+
case "tiers":
797+
return "Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back";
798+
case "tier-chain":
799+
return "Up/Down leg · a/Enter add · x remove · u/d reorder · m mode · Esc back";
800+
case "profiles":
801+
return "Up/Down navigate · a add · e edit · x remove · Esc back";
802+
case "profile-form":
803+
return "Up/Down fields · Left/Right for tier · Enter next/save · Esc cancel";
804+
case "profile-delete":
805+
return "y remove · n cancel · Esc back";
806+
case "model":
807+
return "Up/Down navigate · Enter effort · Esc back";
808+
case "effort":
809+
return "Up/Down navigate · Enter use now · d set as default · Esc back";
810+
case "form":
811+
return "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel";
812+
case "delete":
813+
return "y remove · n cancel · Esc back";
814+
}
815+
})();
816+
const helpLines = helpText !== null ? wrapHelpSegments(helpText.split(" · "), contentWidth) : [];
817+
776818
return (
777819
<Box
778820
flexDirection="column"
779-
paddingX={2}
821+
paddingX={stackFields ? 1 : 2}
780822
paddingY={1}
781823
marginX={1}
782824
marginY={1}
825+
width={Math.max(1, columns - 2)}
783826
>
784827
<Text bold color={color("accent")}>
785828
Agent Configuration
@@ -859,16 +902,18 @@ export function AgentModal({
859902
const assignment = tiers[tier];
860903
const isCursor = i === tierIndex;
861904
const assignmentLabel = formatTierChain(assignment);
905+
const rowDir = stackFields ? "column" : "row";
862906
return (
863-
<Box key={tier} flexDirection="row" gap={2}>
864-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
865-
{isCursor ? ">" : " "}
866-
</Text>
867-
<Box width={10} flexShrink={0}>
907+
<Box key={tier} flexDirection={rowDir} gap={stackFields ? 0 : 2}>
908+
<Box flexDirection="row" gap={1}>
909+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
910+
{isCursor ? ">" : " "}
911+
</Text>
868912
<Text color={isCursor ? color("accent") : color("text")}>{tier}</Text>
869913
</Box>
870914
<Text color={assignment !== undefined ? color("text") : color("muted")}>
871-
{assignmentLabel}
915+
{stackFields ? " " : ""}
916+
{fitTrailingText(assignmentLabel, stackFields ? contentWidth - 2 : Math.max(8, contentWidth - 12))}
872917
</Text>
873918
</Box>
874919
);
@@ -886,17 +931,27 @@ export function AgentModal({
886931
{models.map((m, i) => {
887932
const isActive = selectedProvider?.name === activeProvider && m === activeModel;
888933
const isCursor = i === modelIndex;
934+
const desc = MODEL_DESCRIPTIONS[m];
935+
const namePart = `${isActive ? "* " : " "}${m}`;
936+
const showDescInline = desc !== undefined && !stackFields && namePart.length + desc.length + 4 < contentWidth;
889937
return (
890-
<Box key={m} flexDirection="row" gap={1}>
891-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
892-
{isCursor ? ">" : " "}
893-
</Text>
894-
<Text color={isCursor ? color("accent") : color("text")}>
895-
{isActive ? "* " : " "}
896-
{m}
897-
</Text>
898-
{MODEL_DESCRIPTIONS[m] !== undefined && (
899-
<Text color={color("muted")}>{MODEL_DESCRIPTIONS[m]}</Text>
938+
<Box key={m} flexDirection="column">
939+
<Box flexDirection="row" gap={1}>
940+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
941+
{isCursor ? ">" : " "}
942+
</Text>
943+
<Text color={isCursor ? color("accent") : color("text")}>
944+
{fitTrailingText(namePart, contentWidth - 2)}
945+
</Text>
946+
{showDescInline && (
947+
<Text color={color("muted")}>{desc}</Text>
948+
)}
949+
</Box>
950+
{desc !== undefined && !showDescInline && (
951+
<Text color={color("muted")}>
952+
{" "}
953+
{fitTrailingText(desc, contentWidth - 2)}
954+
</Text>
900955
)}
901956
</Box>
902957
);
@@ -915,17 +970,27 @@ export function AgentModal({
915970
{efforts.map((e, i) => {
916971
const isActive = e === activeEffort;
917972
const isCursor = i === effortIndex;
973+
const desc = EFFORT_DESCRIPTIONS[e];
974+
const namePart = `${isActive ? "* " : " "}${effortLabel(e)}`;
975+
const showDescInline = desc !== undefined && !stackFields && namePart.length + desc.length + 4 < contentWidth;
918976
return (
919-
<Box key={e} flexDirection="row" gap={1}>
920-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
921-
{isCursor ? ">" : " "}
922-
</Text>
923-
<Text color={isCursor ? color("accent") : color("text")}>
924-
{isActive ? "* " : " "}
925-
{effortLabel(e)}
926-
</Text>
927-
{EFFORT_DESCRIPTIONS[e] !== undefined && (
928-
<Text color={color("muted")}>{EFFORT_DESCRIPTIONS[e]}</Text>
977+
<Box key={e} flexDirection="column">
978+
<Box flexDirection="row" gap={1}>
979+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
980+
{isCursor ? ">" : " "}
981+
</Text>
982+
<Text color={isCursor ? color("accent") : color("text")}>
983+
{fitTrailingText(namePart, contentWidth - 2)}
984+
</Text>
985+
{showDescInline && (
986+
<Text color={color("muted")}>{desc}</Text>
987+
)}
988+
</Box>
989+
{desc !== undefined && !showDescInline && (
990+
<Text color={color("muted")}>
991+
{" "}
992+
{fitTrailingText(desc, contentWidth - 2)}
993+
</Text>
929994
)}
930995
</Box>
931996
);
@@ -949,45 +1014,67 @@ export function AgentModal({
9491014
const isCursor = i === formIndex;
9501015
const value = formValues[field];
9511016
const isKeyless = formValues.keyless === "yes";
952-
// gap only between label and value — never between value and caret,
1017+
// gap only between label and value — never between value and caret,
9531018
// or the caret sits after a phantom space the user did not type.
9541019
const showCaret =
9551020
isCursor &&
9561021
field !== "keyless" &&
9571022
!(field === "apiKey" && isKeyless);
1023+
const rawDisplay =
1024+
field === "keyless"
1025+
? null
1026+
: field === "apiKey" && isKeyless
1027+
? "(disabled — keyless provider)"
1028+
: value.length > 0
1029+
? maskInput(field, value)
1030+
: field === "apiKey" && editingProvider !== undefined
1031+
? "leave blank to keep existing"
1032+
: FIELD_HINTS[field];
1033+
// Reserve one cell for the caret so long values do not push it off-screen.
1034+
const fitted =
1035+
rawDisplay === null
1036+
? null
1037+
: fitTrailingText(rawDisplay, showCaret ? Math.max(1, valueWidth - 1) : valueWidth);
9581038
return (
959-
<Box key={field} flexDirection="row" gap={1}>
960-
<Box width={16} flexShrink={0}>
1039+
<Box
1040+
key={field}
1041+
flexDirection={stackFields ? "column" : "row"}
1042+
gap={stackFields ? 0 : 1}
1043+
marginBottom={stackFields ? 1 : 0}
1044+
>
1045+
<Box width={stackFields ? undefined : providerLabelWidth} flexShrink={0}>
9611046
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
9621047
{FIELD_LABELS[field]}
9631048
</Text>
9641049
</Box>
965-
{field === "keyless" ? (
966-
<Text color={value === "yes" ? color("accent") : color("muted")}>
967-
{isCursor ? "< " : " "}
968-
{value === "yes" ? "yes" : "no"}
969-
{isCursor ? " >" : ""}
970-
</Text>
971-
) : field === "apiKey" && isKeyless ? (
972-
<Text color={color("muted")}>(disabled — keyless provider)</Text>
973-
) : (
974-
<Box>
975-
<Text color={value.length > 0 ? color("text") : color("muted")}>
976-
{value.length > 0
977-
? maskInput(field, value)
978-
: field === "apiKey" && editingProvider !== undefined
979-
? "leave blank to keep existing"
980-
: FIELD_HINTS[field]}
1050+
<Box flexDirection="row" gap={0}>
1051+
{field === "keyless" ? (
1052+
<Text color={value === "yes" ? color("accent") : color("muted")}>
1053+
{isCursor ? "< " : " "}
1054+
{value === "yes" ? "yes" : "no"}
1055+
{isCursor ? " >" : ""}
9811056
</Text>
982-
{showCaret && <Text color={color("accent")}>|</Text>}
983-
</Box>
984-
)}
1057+
) : (
1058+
<Text
1059+
color={
1060+
field === "apiKey" && isKeyless
1061+
? color("muted")
1062+
: value.length > 0
1063+
? color("text")
1064+
: color("muted")
1065+
}
1066+
>
1067+
{fitted}
1068+
</Text>
1069+
)}
1070+
{showCaret && <Text color={color("accent")}>|</Text>}
1071+
</Box>
9851072
</Box>
9861073
);
9871074
})}
9881075
{formError !== null && (
9891076
<Box marginTop={1}>
990-
<Text color={color("danger")}>{formError}</Text>
1077+
<Text color={color("danger")}>{fitTrailingText(formError, contentWidth)}</Text>
9911078
</Box>
9921079
)}
9931080
</Box>
@@ -1000,18 +1087,23 @@ export function AgentModal({
10001087
)}
10011088
{profiles.map((p, i) => {
10021089
const isCursor = i === profileIndex;
1090+
const meta = `${p.tier !== undefined ? `[${p.tier}]` : ""}${p.description !== undefined ? ` ${p.description}` : ""}`.trim();
10031091
return (
1004-
<Box key={p.id} flexDirection="row" gap={2}>
1005-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
1006-
{isCursor ? ">" : " "}
1007-
</Text>
1008-
<Box width={20} flexShrink={0}>
1009-
<Text color={isCursor ? color("accent") : color("text")}>{p.id}</Text>
1092+
<Box key={p.id} flexDirection={stackFields ? "column" : "row"} gap={stackFields ? 0 : 2}>
1093+
<Box flexDirection="row" gap={1}>
1094+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
1095+
{isCursor ? ">" : " "}
1096+
</Text>
1097+
<Text color={isCursor ? color("accent") : color("text")}>
1098+
{fitTrailingText(p.id, stackFields ? contentWidth - 2 : 20)}
1099+
</Text>
10101100
</Box>
1011-
<Text color={color("muted")}>
1012-
{p.tier !== undefined ? `[${p.tier}]` : ""}
1013-
{p.description !== undefined ? ` ${p.description}` : ""}
1014-
</Text>
1101+
{meta.length > 0 && (
1102+
<Text color={color("muted")}>
1103+
{stackFields ? " " : ""}
1104+
{fitTrailingText(meta, stackFields ? contentWidth - 2 : Math.max(8, contentWidth - 24))}
1105+
</Text>
1106+
)}
10151107
</Box>
10161108
);
10171109
})}
@@ -1032,53 +1124,62 @@ export function AgentModal({
10321124
</Text>
10331125
{PROFILE_FORM_FIELDS.map((field, i) => {
10341126
const isCursor = i === profileFormIndex;
1127+
const showCaret = isCursor && field !== "tier";
1128+
const raw =
1129+
field === "tier"
1130+
? null
1131+
: profileFormValues[field].length > 0
1132+
? profileFormValues[field]
1133+
: PROFILE_FIELD_HINTS[field];
1134+
const fitted =
1135+
raw === null
1136+
? null
1137+
: fitTrailingText(raw, showCaret ? Math.max(1, valueWidth - 1) : valueWidth);
10351138
return (
1036-
<Box key={field} flexDirection="row" gap={1}>
1037-
<Box width={14} flexShrink={0}>
1139+
<Box
1140+
key={field}
1141+
flexDirection={stackFields ? "column" : "row"}
1142+
gap={stackFields ? 0 : 1}
1143+
marginBottom={stackFields ? 1 : 0}
1144+
>
1145+
<Box width={stackFields ? undefined : profileLabelWidth} flexShrink={0}>
10381146
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
10391147
{PROFILE_FIELD_LABELS[field]}
10401148
</Text>
10411149
</Box>
1042-
{field === "tier" ? (
1043-
<Text color={profileFormValues.tier.length > 0 ? color("text") : color("muted")}>
1044-
{isCursor ? "< " : " "}
1045-
{profileFormValues.tier.length > 0 ? profileFormValues.tier : "none"}
1046-
{isCursor ? " >" : ""}
1047-
</Text>
1048-
) : (
1049-
<Box>
1050-
<Text color={profileFormValues[field].length > 0 ? color("text") : color("muted")}>
1051-
{profileFormValues[field].length > 0 ? profileFormValues[field] : PROFILE_FIELD_HINTS[field]}
1150+
<Box flexDirection="row" gap={0}>
1151+
{field === "tier" ? (
1152+
<Text color={profileFormValues.tier.length > 0 ? color("text") : color("muted")}>
1153+
{isCursor ? "< " : " "}
1154+
{profileFormValues.tier.length > 0 ? profileFormValues.tier : "none"}
1155+
{isCursor ? " >" : ""}
10521156
</Text>
1053-
{isCursor && <Text color={color("accent")}>|</Text>}
1054-
</Box>
1055-
)}
1157+
) : (
1158+
<Text
1159+
color={profileFormValues[field].length > 0 ? color("text") : color("muted")}
1160+
>
1161+
{fitted}
1162+
</Text>
1163+
)}
1164+
{showCaret && <Text color={color("accent")}>|</Text>}
1165+
</Box>
10561166
</Box>
10571167
);
10581168
})}
10591169
{profileFormError !== null && (
10601170
<Box marginTop={1}>
1061-
<Text color={color("danger")}>{profileFormError}</Text>
1171+
<Text color={color("danger")}>{fitTrailingText(profileFormError, contentWidth)}</Text>
10621172
</Box>
10631173
)}
10641174
</Box>
10651175
)}
10661176

1067-
<Box marginTop={1}>
1068-
<Text dimColor>
1069-
{step === "provider" && "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close"}
1070-
{step === "tiers" &&
1071-
"Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back"}
1072-
{step === "tier-chain" &&
1073-
"Up/Down leg · a/Enter add · x remove · u/d reorder · m mode · Esc back"}
1074-
{step === "profiles" && "Up/Down navigate · a add · e edit · x remove · Esc back"}
1075-
{step === "profile-form" && "Up/Down fields · Left/Right for tier · Enter next/save · Esc cancel"}
1076-
{step === "profile-delete" && "y remove · n cancel · Esc back"}
1077-
{step === "model" && "Up/Down navigate · Enter effort · Esc back"}
1078-
{step === "effort" && "Up/Down navigate · Enter use now · d set as default · Esc back"}
1079-
{step === "form" && "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel"}
1080-
{step === "delete" && "y remove · n cancel · Esc back"}
1081-
</Text>
1177+
<Box marginTop={1} flexDirection="column">
1178+
{helpLines.map((line, i) => (
1179+
<Text key={i} dimColor>
1180+
{line}
1181+
</Text>
1182+
))}
10821183
</Box>
10831184
</Box>
10841185
);

0 commit comments

Comments
 (0)