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:
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user