From 416b30a8d5368536cb17392c5788affa165df7cd Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 19 Aug 2026 16:26:57 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/commands/launch-help.ts | 3 +- .../src/config/settings-schema.ts | 8 +++ .../src/modes/components/settings-defs.ts | 12 ++++- .../src/modes/components/settings-selector.ts | 46 +++++----------- .../src/modes/theme/tui-adapters.ts | 4 ++ packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/components/settings-list.ts | 54 +++++++++++++++---- packages/tui/test/settings-list.test.ts | 37 +++++++++++++ 9 files changed, 126 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cc445bdae..cd31a58e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/commands/launch-help.ts b/packages/coding-agent/src/commands/launch-help.ts index ffa7b0b07..1a2109303 100644 --- a/packages/coding-agent/src/commands/launch-help.ts +++ b/packages/coding-agent/src/commands/launch-help.ts @@ -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({ diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 4ddcd045a..184597293 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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", }, }, diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index a66c15ad9..29dd06000 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -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" }; diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index f18ada5ca..ac04e1d94 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -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, }; } } diff --git a/packages/coding-agent/src/modes/theme/tui-adapters.ts b/packages/coding-agent/src/modes/theme/tui-adapters.ts index 571854191..c6be3cc3a 100644 --- a/packages/coding-agent/src/modes/theme/tui-adapters.ts +++ b/packages/coding-agent/src/modes/theme/tui-adapters.ts @@ -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) => diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 0b33e4046..e48cce402 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 5e221ad6a..95d1c3503 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -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)); diff --git a/packages/tui/test/settings-list.test.ts b/packages/tui/test/settings-list.test.ts index 8089f6595..e24ecb77a 100644 --- a/packages/tui/test/settings-list.test.ts +++ b/packages/tui/test/settings-list.test.ts @@ -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) => ({