Skip to content

Commit 09378e6

Browse files
committed
Make settings and agent forms reflow on narrow terminals
Extract shared form-reflow helpers and wire Settings + Agent Configuration so labels stack, long values keep the caret on-screen, and help footers wrap at ~40-60 columns. Closes CL-5341
1 parent 2921e45 commit 09378e6

4 files changed

Lines changed: 368 additions & 110 deletions

File tree

src/tui/components/agent-modal.tsx

Lines changed: 197 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".
@@ -254,6 +261,15 @@ export function AgentModal({
254261
unauthedProviders,
255262
onRequestLogin,
256263
}: AgentModalProps): ReactNode {
264+
const { columns } = useTerminalSize();
265+
const stackFields = columns < STACK_FORM_COLUMNS;
266+
const contentWidth = formContentWidth(columns, stackFields);
267+
// Label column widths used in row layout; stacked layout uses full content width for values.
268+
const providerLabelWidth = 16;
269+
const profileLabelWidth = 14;
270+
const valueWidth = stackFields
271+
? contentWidth
272+
: Math.max(8, contentWidth - Math.max(providerLabelWidth, profileLabelWidth) - 1);
257273
const initialProvider = Math.max(
258274
0,
259275
providers.findIndex((p) => p.name === activeProvider),
@@ -785,13 +801,40 @@ export function AgentModal({
785801
setFormError(null);
786802
});
787803

804+
const helpText = ((): string | null => {
805+
switch (step) {
806+
case "provider":
807+
return "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close";
808+
case "tiers":
809+
return "Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back";
810+
case "tier-chain":
811+
return "Up/Down leg · a/Enter add · x remove · u/d reorder · m mode · Esc back";
812+
case "profiles":
813+
return "Up/Down navigate · a add · e edit · x remove · Esc back";
814+
case "profile-form":
815+
return "Up/Down fields · Left/Right for tier · Enter next/save · Esc cancel";
816+
case "profile-delete":
817+
return "y remove · n cancel · Esc back";
818+
case "model":
819+
return "Up/Down navigate · Enter effort · Esc back";
820+
case "effort":
821+
return "Up/Down navigate · Enter use now · d set as default · Esc back";
822+
case "form":
823+
return "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel";
824+
case "delete":
825+
return "y remove · n cancel · Esc back";
826+
}
827+
})();
828+
const helpLines = helpText !== null ? wrapHelpSegments(helpText.split(" · "), contentWidth) : [];
829+
788830
return (
789831
<Box
790832
flexDirection="column"
791-
paddingX={2}
833+
paddingX={stackFields ? 1 : 2}
792834
paddingY={1}
793835
marginX={1}
794836
marginY={1}
837+
width={Math.max(16, columns - 2)}
795838
>
796839
<Text bold color={color("accent")}>
797840
Agent Configuration
@@ -871,16 +914,18 @@ export function AgentModal({
871914
const assignment = tiers[tier];
872915
const isCursor = i === tierIndex;
873916
const assignmentLabel = formatTierChain(assignment);
917+
const rowDir = stackFields ? "column" : "row";
874918
return (
875-
<Box key={tier} flexDirection="row" gap={2}>
876-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
877-
{isCursor ? ">" : " "}
878-
</Text>
879-
<Box width={10} flexShrink={0}>
919+
<Box key={tier} flexDirection={rowDir} gap={stackFields ? 0 : 2}>
920+
<Box flexDirection="row" gap={1}>
921+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
922+
{isCursor ? ">" : " "}
923+
</Text>
880924
<Text color={isCursor ? color("accent") : color("text")}>{tier}</Text>
881925
</Box>
882926
<Text color={assignment !== undefined ? color("text") : color("muted")}>
883-
{assignmentLabel}
927+
{stackFields ? " " : ""}
928+
{fitTrailingText(assignmentLabel, stackFields ? contentWidth - 2 : Math.max(8, contentWidth - 12))}
884929
</Text>
885930
</Box>
886931
);
@@ -898,17 +943,27 @@ export function AgentModal({
898943
{models.map((m, i) => {
899944
const isActive = selectedProvider?.name === activeProvider && m === activeModel;
900945
const isCursor = i === modelIndex;
946+
const desc = MODEL_DESCRIPTIONS[m];
947+
const namePart = `${isActive ? "* " : " "}${m}`;
948+
const showDescInline = desc !== undefined && !stackFields && namePart.length + desc.length + 4 < contentWidth;
901949
return (
902-
<Box key={m} flexDirection="row" gap={1}>
903-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
904-
{isCursor ? ">" : " "}
905-
</Text>
906-
<Text color={isCursor ? color("accent") : color("text")}>
907-
{isActive ? "* " : " "}
908-
{m}
909-
</Text>
910-
{MODEL_DESCRIPTIONS[m] !== undefined && (
911-
<Text color={color("muted")}>{MODEL_DESCRIPTIONS[m]}</Text>
950+
<Box key={m} flexDirection="column">
951+
<Box flexDirection="row" gap={1}>
952+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
953+
{isCursor ? ">" : " "}
954+
</Text>
955+
<Text color={isCursor ? color("accent") : color("text")}>
956+
{fitTrailingText(namePart, contentWidth - 2)}
957+
</Text>
958+
{showDescInline && (
959+
<Text color={color("muted")}>{desc}</Text>
960+
)}
961+
</Box>
962+
{desc !== undefined && !showDescInline && (
963+
<Text color={color("muted")}>
964+
{" "}
965+
{fitTrailingText(desc, contentWidth - 2)}
966+
</Text>
912967
)}
913968
</Box>
914969
);
@@ -927,17 +982,27 @@ export function AgentModal({
927982
{efforts.map((e, i) => {
928983
const isActive = e === activeEffort;
929984
const isCursor = i === effortIndex;
985+
const desc = EFFORT_DESCRIPTIONS[e];
986+
const namePart = `${isActive ? "* " : " "}${effortLabel(e)}`;
987+
const showDescInline = desc !== undefined && !stackFields && namePart.length + desc.length + 4 < contentWidth;
930988
return (
931-
<Box key={e} flexDirection="row" gap={1}>
932-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
933-
{isCursor ? ">" : " "}
934-
</Text>
935-
<Text color={isCursor ? color("accent") : color("text")}>
936-
{isActive ? "* " : " "}
937-
{effortLabel(e)}
938-
</Text>
939-
{EFFORT_DESCRIPTIONS[e] !== undefined && (
940-
<Text color={color("muted")}>{EFFORT_DESCRIPTIONS[e]}</Text>
989+
<Box key={e} flexDirection="column">
990+
<Box flexDirection="row" gap={1}>
991+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
992+
{isCursor ? ">" : " "}
993+
</Text>
994+
<Text color={isCursor ? color("accent") : color("text")}>
995+
{fitTrailingText(namePart, contentWidth - 2)}
996+
</Text>
997+
{showDescInline && (
998+
<Text color={color("muted")}>{desc}</Text>
999+
)}
1000+
</Box>
1001+
{desc !== undefined && !showDescInline && (
1002+
<Text color={color("muted")}>
1003+
{" "}
1004+
{fitTrailingText(desc, contentWidth - 2)}
1005+
</Text>
9411006
)}
9421007
</Box>
9431008
);
@@ -961,39 +1026,66 @@ export function AgentModal({
9611026
const isCursor = i === formIndex;
9621027
const value = formValues[field];
9631028
const isKeyless = formValues.keyless === "yes";
1029+
const showCaret =
1030+
isCursor &&
1031+
field !== "keyless" &&
1032+
field !== "bifrostVirtualKey" &&
1033+
!(field === "apiKey" && isKeyless);
1034+
const rawDisplay =
1035+
field === "keyless" || field === "bifrostVirtualKey"
1036+
? null
1037+
: field === "apiKey" && isKeyless
1038+
? "(disabled — keyless provider)"
1039+
: value.length > 0
1040+
? maskInput(field, value)
1041+
: field === "apiKey" && editingProvider !== undefined
1042+
? "leave blank to keep existing"
1043+
: FIELD_HINTS[field];
1044+
// Reserve one cell for the caret so long values do not push it off-screen.
1045+
const fitted =
1046+
rawDisplay === null
1047+
? null
1048+
: fitTrailingText(rawDisplay, showCaret ? Math.max(1, valueWidth - 1) : valueWidth);
9641049
return (
965-
<Box key={field} flexDirection="row" gap={1}>
966-
<Box width={16} flexShrink={0}>
1050+
<Box
1051+
key={field}
1052+
flexDirection={stackFields ? "column" : "row"}
1053+
gap={stackFields ? 0 : 1}
1054+
marginBottom={stackFields ? 1 : 0}
1055+
>
1056+
<Box width={stackFields ? undefined : providerLabelWidth} flexShrink={0}>
9671057
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
9681058
{FIELD_LABELS[field]}
9691059
</Text>
9701060
</Box>
971-
{field === "keyless" || field === "bifrostVirtualKey" ? (
972-
<Text color={value === "yes" ? color("accent") : color("muted")}>
973-
{isCursor ? "< " : " "}
974-
{value === "yes" ? "yes" : "no"}
975-
{isCursor ? " >" : ""}
976-
</Text>
977-
) : field === "apiKey" && isKeyless ? (
978-
<Text color={color("muted")}>(disabled — keyless provider)</Text>
979-
) : (
980-
<Text color={value.length > 0 ? color("text") : color("muted")}>
981-
{value.length > 0
982-
? maskInput(field, value)
983-
: field === "apiKey" && editingProvider !== undefined
984-
? "leave blank to keep existing"
985-
: FIELD_HINTS[field]}
986-
</Text>
987-
)}
988-
{isCursor && field !== "keyless" && field !== "bifrostVirtualKey" && !(field === "apiKey" && isKeyless) && (
989-
<Text color={color("accent")}>|</Text>
990-
)}
1061+
<Box flexDirection="row" gap={0}>
1062+
{field === "keyless" || field === "bifrostVirtualKey" ? (
1063+
<Text color={value === "yes" ? color("accent") : color("muted")}>
1064+
{isCursor ? "< " : " "}
1065+
{value === "yes" ? "yes" : "no"}
1066+
{isCursor ? " >" : ""}
1067+
</Text>
1068+
) : (
1069+
<Text
1070+
color={
1071+
field === "apiKey" && isKeyless
1072+
? color("muted")
1073+
: value.length > 0
1074+
? color("text")
1075+
: color("muted")
1076+
}
1077+
>
1078+
{fitted}
1079+
</Text>
1080+
)}
1081+
{showCaret && <Text color={color("accent")}>|</Text>}
1082+
</Box>
9911083
</Box>
9921084
);
9931085
})}
9941086
{formError !== null && (
9951087
<Box marginTop={1}>
996-
<Text color={color("danger")}>{formError}</Text>
1088+
<Text color={color("danger")}>{fitTrailingText(formError, contentWidth)}</Text>
9971089
</Box>
9981090
)}
9991091
</Box>
@@ -1006,18 +1098,23 @@ export function AgentModal({
10061098
)}
10071099
{profiles.map((p, i) => {
10081100
const isCursor = i === profileIndex;
1101+
const meta = `${p.tier !== undefined ? `[${p.tier}]` : ""}${p.description !== undefined ? ` ${p.description}` : ""}`.trim();
10091102
return (
1010-
<Box key={p.id} flexDirection="row" gap={2}>
1011-
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
1012-
{isCursor ? ">" : " "}
1013-
</Text>
1014-
<Box width={20} flexShrink={0}>
1015-
<Text color={isCursor ? color("accent") : color("text")}>{p.id}</Text>
1103+
<Box key={p.id} flexDirection={stackFields ? "column" : "row"} gap={stackFields ? 0 : 2}>
1104+
<Box flexDirection="row" gap={1}>
1105+
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
1106+
{isCursor ? ">" : " "}
1107+
</Text>
1108+
<Text color={isCursor ? color("accent") : color("text")}>
1109+
{fitTrailingText(p.id, stackFields ? contentWidth - 2 : 20)}
1110+
</Text>
10161111
</Box>
1017-
<Text color={color("muted")}>
1018-
{p.tier !== undefined ? `[${p.tier}]` : ""}
1019-
{p.description !== undefined ? ` ${p.description}` : ""}
1020-
</Text>
1112+
{meta.length > 0 && (
1113+
<Text color={color("muted")}>
1114+
{stackFields ? " " : ""}
1115+
{fitTrailingText(meta, stackFields ? contentWidth - 2 : Math.max(8, contentWidth - 24))}
1116+
</Text>
1117+
)}
10211118
</Box>
10221119
);
10231120
})}
@@ -1038,53 +1135,62 @@ export function AgentModal({
10381135
</Text>
10391136
{PROFILE_FORM_FIELDS.map((field, i) => {
10401137
const isCursor = i === profileFormIndex;
1138+
const showCaret = isCursor && field !== "tier";
1139+
const raw =
1140+
field === "tier"
1141+
? null
1142+
: profileFormValues[field].length > 0
1143+
? profileFormValues[field]
1144+
: PROFILE_FIELD_HINTS[field];
1145+
const fitted =
1146+
raw === null
1147+
? null
1148+
: fitTrailingText(raw, showCaret ? Math.max(1, valueWidth - 1) : valueWidth);
10411149
return (
1042-
<Box key={field} flexDirection="row" gap={1}>
1043-
<Box width={14} flexShrink={0}>
1150+
<Box
1151+
key={field}
1152+
flexDirection={stackFields ? "column" : "row"}
1153+
gap={stackFields ? 0 : 1}
1154+
marginBottom={stackFields ? 1 : 0}
1155+
>
1156+
<Box width={stackFields ? undefined : profileLabelWidth} flexShrink={0}>
10441157
<Text color={isCursor ? color("accent") : color("muted")} bold={isCursor}>
10451158
{PROFILE_FIELD_LABELS[field]}
10461159
</Text>
10471160
</Box>
1048-
{field === "tier" ? (
1049-
<Text color={profileFormValues.tier.length > 0 ? color("text") : color("muted")}>
1050-
{isCursor ? "< " : " "}
1051-
{profileFormValues.tier.length > 0 ? profileFormValues.tier : "none"}
1052-
{isCursor ? " >" : ""}
1053-
</Text>
1054-
) : (
1055-
<>
1056-
<Text color={profileFormValues[field].length > 0 ? color("text") : color("muted")}>
1057-
{profileFormValues[field].length > 0 ? profileFormValues[field] : PROFILE_FIELD_HINTS[field]}
1161+
<Box flexDirection="row" gap={0}>
1162+
{field === "tier" ? (
1163+
<Text color={profileFormValues.tier.length > 0 ? color("text") : color("muted")}>
1164+
{isCursor ? "< " : " "}
1165+
{profileFormValues.tier.length > 0 ? profileFormValues.tier : "none"}
1166+
{isCursor ? " >" : ""}
10581167
</Text>
1059-
{isCursor && <Text color={color("accent")}>|</Text>}
1060-
</>
1061-
)}
1168+
) : (
1169+
<Text
1170+
color={profileFormValues[field].length > 0 ? color("text") : color("muted")}
1171+
>
1172+
{fitted}
1173+
</Text>
1174+
)}
1175+
{showCaret && <Text color={color("accent")}>|</Text>}
1176+
</Box>
10621177
</Box>
10631178
);
10641179
})}
10651180
{profileFormError !== null && (
10661181
<Box marginTop={1}>
1067-
<Text color={color("danger")}>{profileFormError}</Text>
1182+
<Text color={color("danger")}>{fitTrailingText(profileFormError, contentWidth)}</Text>
10681183
</Box>
10691184
)}
10701185
</Box>
10711186
)}
10721187

1073-
<Box marginTop={1}>
1074-
<Text dimColor>
1075-
{step === "provider" && "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close"}
1076-
{step === "tiers" &&
1077-
"Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back"}
1078-
{step === "tier-chain" &&
1079-
"Up/Down leg · a/Enter add · x remove · u/d reorder · m mode · Esc back"}
1080-
{step === "profiles" && "Up/Down navigate · a add · e edit · x remove · Esc back"}
1081-
{step === "profile-form" && "Up/Down fields · Left/Right for tier · Enter next/save · Esc cancel"}
1082-
{step === "profile-delete" && "y remove · n cancel · Esc back"}
1083-
{step === "model" && "Up/Down navigate · Enter effort · Esc back"}
1084-
{step === "effort" && "Up/Down navigate · Enter use now · d set as default · Esc back"}
1085-
{step === "form" && "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel"}
1086-
{step === "delete" && "y remove · n cancel · Esc back"}
1087-
</Text>
1188+
<Box marginTop={1} flexDirection="column">
1189+
{helpLines.map((line, i) => (
1190+
<Text key={i} dimColor>
1191+
{line}
1192+
</Text>
1193+
))}
10881194
</Box>
10891195
</Box>
10901196
);

0 commit comments

Comments
 (0)