feat(tui): supported risk notes with warning glyphs in settings lists

- Add support for risk notes and warning markers on setting items in the TUI settings list component.
- Update the external thinking setting schema and help command to include a warning about provider abuse enforcement.
This commit is contained in:
can1357
2026-08-19 16:26:57 +02:00
parent 5cbdd740bf
commit 416b30a8d5
9 changed files with 126 additions and 46 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- `/settings` rows can now carry a risk note: a warning glyph on the row plus a warning-colored line above the description. `External Thinking` (`externalThinking`, `--external-thinking`) is the first user — providers have flagged the request shape it produces as abuse, up to account-level enforcement, so both the settings entry and `--help` now say so.
## [17.3.8] - 2026-08-19
### Added
@@ -78,7 +78,8 @@ export const launchHelp = {
description: "Enable the advisor runtime (passively reviews each turn and injects notes)",
}),
"external-thinking": Flags.boolean({
description: "Use a private scratchpad while disabling supported GPT, Claude, and Gemini reasoning",
description:
"Use a private scratchpad while disabling supported GPT, Claude, and Gemini reasoning (at your own risk: providers have flagged this request shape as abuse)",
}),
hook: Flags.string({ description: "Load a hook/extension file (can be used multiple times)", multiple: true }),
extension: Flags.string({
@@ -199,6 +199,12 @@ interface UiBase {
group?: string;
label: string;
description: string;
/**
* Risk note. Marks the settings row with a warning glyph and renders above
* the description in warning styling. For settings that can get the user
* rate-limited, flagged, or banned — not for merely advanced options.
*/
warning?: string;
/** Condition function name - setting only shown when true */
condition?: string;
}
@@ -1139,6 +1145,8 @@ export const SETTINGS_SCHEMA = {
group: "Thinking",
label: "External Thinking",
description: "Private scratchpad; not shown to user. Disables supported GPT, Claude, and Gemini reasoning",
warning:
"At your own risk: providers have flagged this request shape as abuse, up to account-level enforcement",
},
},
@@ -36,6 +36,8 @@ interface BaseSettingDef {
path: SettingPath;
label: string;
description: string;
/** Risk note shown in warning styling; set for settings that can get the user flagged or banned. */
warning?: string;
tab: SettingTab;
/** Section within the tab; items are ordered by TAB_GROUPS[tab] and rendered under a heading row. */
group?: string;
@@ -162,7 +164,15 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
const schemaType = getType(path);
const condition = ui.condition ? CONDITIONS[ui.condition] : undefined;
const base = { path, label: ui.label, description: ui.description, tab: ui.tab, group: ui.group, condition };
const base = {
path,
label: ui.label,
description: ui.description,
warning: ui.warning,
tab: ui.tab,
group: ui.group,
condition,
};
if (schemaType === "boolean") {
return { ...base, type: "boolean" };
@@ -927,67 +927,47 @@ export class SettingsSelectorComponent implements Component {
}
const currentValue = this.#getCurrentValue(def);
const changed = this.#isChanged(def, currentValue);
const item = {
id: def.path,
label: def.label,
description: def.description,
warning: def.warning,
changed: this.#isChanged(def, currentValue),
};
switch (def.type) {
case "boolean":
return {
id: def.path,
label: def.label,
description: def.description,
currentValue: currentValue ? "true" : "false",
values: ["true", "false"],
changed,
};
return { ...item, currentValue: currentValue ? "true" : "false", values: ["true", "false"] };
case "enum":
return {
id: def.path,
label: def.label,
description: def.description,
currentValue: String(currentValue ?? ""),
values: [...def.values],
changed,
};
return { ...item, currentValue: String(currentValue ?? ""), values: [...def.values] };
case "submenu":
return {
id: def.path,
label: def.label,
description: def.description,
...item,
currentValue: this.#getSubmenuCurrentValue(def.path, currentValue),
submenu: (cv, done) => this.#createSubmenu(def, cv, done),
changed,
};
case "text":
return {
id: def.path,
label: def.label,
description: def.description,
...item,
currentValue: this.#formatTextInputValue(def, currentValue),
submenu: (cv, done) => this.#createTextInput(def, cv, done),
changed,
};
case "providerLimits":
return {
id: def.path,
label: def.label,
description: def.description,
...item,
currentValue: this.#formatProviderLimitsValue(currentValue),
submenu: (_cv, done) => this.#createProviderLimitsInput(done),
changed,
};
case "multiselect":
return {
id: def.path,
label: def.label,
description: def.description,
...item,
currentValue: this.#formatMultiSelectValue(def, currentValue),
submenu: (_cv, done) => this.#createMultiSelect(def, done),
changed,
};
}
}
@@ -254,6 +254,8 @@ export function getSettingsListTheme(): SettingsListTheme {
label: (text: string) => text,
value: (text: string) => text,
description: (text: string) => text,
warning: (text: string) => text,
warningMark: "!",
cursor: "> ",
hint: (text: string) => text,
heading: (text: string) => text,
@@ -267,6 +269,8 @@ export function getSettingsListTheme(): SettingsListTheme {
value: (text: string, selected: boolean, changed: boolean) =>
changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : theme.fg("muted", text),
description: (text: string) => theme.fg("dim", text),
warning: (text: string) => theme.fg("warning", text),
warningMark: theme.status.warning,
cursor: theme.fg("accent", `${theme.nav.cursor} `),
hint: (text: string) => theme.fg("dim", text),
heading: (text: string, dimmed: boolean) =>
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added support for warning risk notes and row markers in settings lists
## [17.3.8] - 2026-08-19
### Fixed
+43 -11
View File
@@ -20,6 +20,8 @@ export interface SettingItem {
label: string;
/** Optional description shown when selected */
description?: string;
/** Optional risk note shown in warning styling above the description, with a glyph on the row. */
warning?: string;
/** Current value to display (right side) */
currentValue: string;
/** If provided, Enter/Space cycles through these values */
@@ -36,6 +38,10 @@ export interface SettingsListTheme {
label: (text: string, selected: boolean, changed: boolean) => string;
value: (text: string, selected: boolean, changed: boolean) => string;
description: (text: string) => string;
/** Style for risk notes and the row warning glyph. Falls back to `description` when omitted. */
warning?: (text: string) => string;
/** Glyph marking rows that carry a `warning`. Omitted hides the row marker. */
warningMark?: string;
cursor: string;
hint: (text: string) => string;
/** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */
@@ -78,12 +84,15 @@ export interface SettingsListOptions {
sidebarWidth?: number;
}
/** Searchable text for a setting item: label, id, value, description, and cycle values. */
/** Searchable text for a setting item: label, id, value, description, warning, and cycle values. */
export function getSettingItemFilterText(item: SettingItem): string {
let text = `${item.label} ${item.id} ${item.currentValue}`;
if (item.description) {
text += ` ${item.description}`;
}
if (item.warning) {
text += ` ${item.warning}`;
}
if (item.values) {
text += ` ${item.values.join(" ")}`;
}
@@ -477,6 +486,11 @@ export class SettingsList implements Component {
return this.#padLines(this.#renderMainList(width));
}
/** Warning glyph suffix for a row that carries a risk note, or "" when none applies. */
#warningMark(item: SettingItem): string {
return item.warning && this.#theme.warningMark ? ` ${this.#theme.warningMark}` : "";
}
#renderItemRow(
item: SettingItem,
index: number,
@@ -495,7 +509,9 @@ export class SettingsList implements Component {
const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
const prefix = isSelected ? this.#theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const mark = this.#warningMark(item);
const labelPlain = item.label + mark;
const labelPad = padding(Math.max(0, maxLabelWidth - visibleWidth(labelPlain)));
const separator = " ";
const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
const valuePlain = truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, Ellipsis.Omit);
@@ -504,11 +520,13 @@ export class SettingsList implements Component {
// under one dim wash so inner label/value colors don't fight it.
if (dimmed && !isSelected) {
const text = this.#theme.hint(
truncateToWidth(` ${labelPadded}${separator}${valuePlain}`, Math.max(0, rowWidth)),
truncateToWidth(` ${labelPlain}${labelPad}${separator}${valuePlain}`, Math.max(0, rowWidth)),
);
return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text;
}
const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
const warningStyle = this.#theme.warning ?? this.#theme.description;
const labelText =
this.#theme.label(item.label, isSelected, item.changed === true) + (mark ? warningStyle(mark) : "") + labelPad;
const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true);
const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
// Pointer hover paints a band behind the whole row, distinct from the
@@ -550,7 +568,9 @@ export class SettingsList implements Component {
0,
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
);
const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
const labelWidths = this.#filteredItems
.filter(item => !item.heading)
.map(item => visibleWidth(item.label + this.#warningMark(item)));
const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
@@ -589,15 +609,25 @@ export class SettingsList implements Component {
// Description area: 1 blank + exactly 3 rows, clamped with an ellipsis,
// so moving between items with/without descriptions never shifts rows.
// The risk note leads so it survives the clamp when both are present.
lines.push("");
const selectedItem = this.#filteredItems[this.#selectedIndex];
const descLines: string[] = [];
if (selectedItem?.description && !selectedItem.heading) {
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
for (const line of wrappedDesc.slice(0, 3)) {
descLines.push(this.#theme.description(` ${line}`));
if (selectedItem && !selectedItem.heading) {
if (selectedItem.warning) {
const warningStyle = this.#theme.warning ?? this.#theme.description;
const mark = this.#theme.warningMark ? `${this.#theme.warningMark} ` : "";
for (const line of wrapTextWithAnsi(`${mark}${selectedItem.warning}`, width - 4)) {
descLines.push(warningStyle(` ${line}`));
}
}
if (wrappedDesc.length > 3) {
if (selectedItem.description) {
for (const line of wrapTextWithAnsi(selectedItem.description, width - 4)) {
descLines.push(this.#theme.description(` ${line}`));
}
}
if (descLines.length > 3) {
descLines.splice(3);
descLines[2] = truncateToWidth(`${descLines[2]}…`, width);
}
}
@@ -659,7 +689,9 @@ export class SettingsList implements Component {
Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
);
// Label column width spans all items so the layout stays stable across sections.
const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
const labelWidths = this.#filteredItems
.filter(item => !item.heading)
.map(item => visibleWidth(item.label + this.#warningMark(item)));
const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
const overflow = this.#filteredItems.length > viewportHeight;
const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0));
+37
View File
@@ -71,6 +71,43 @@ describe("SettingsList", () => {
expect(output).not.toContain("[changed-label]Default");
});
it("marks a warned row with the glyph and leads the note area with the risk note", () => {
const themed: SettingsListTheme = {
...testTheme,
warning: (text: string) => `[warn]${text}`,
warningMark: "!",
};
const list = new SettingsList(
[
{ id: "safe", label: "Safe", currentValue: "off", values: ["off", "on"] },
{
id: "risky",
label: "Risky",
description: "x".repeat(400),
warning: "may get you banned",
currentValue: "off",
values: ["off", "on"],
},
],
5,
themed,
() => {},
() => {},
);
// Marker rides the row itself, so the risk is visible before selecting it.
const unselected = list.render(80).join("\n");
expect(unselected).toContain("Risky[warn] !");
expect(unselected).not.toContain("Safe[warn] !");
expect(unselected).not.toContain("may get you banned");
list.selectItem("risky");
const selected = list.render(80).join("\n");
// The note leads the 3-row note area, so a long description cannot clamp it away.
expect(selected).toContain("[warn] ! may get you banned");
expect(selected).toContain("x".repeat(10));
});
it("renders long settings tabs through a scrollbar viewport", () => {
const list = new SettingsList(
Array.from({ length: 6 }, (_, i) => ({