free-coding-models 0.5.81 → 0.5.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,8 @@ import { renderRouterDashboard as renderRouterDashboardOverlay } from '../core/r
25
25
  import { renderPlayground as renderPlaygroundOverlay } from '../core/playground.js'
26
26
  import { themeColors, getThemeStatusLabel, getProviderRgb } from './theme.js'
27
27
  import { getProviderBillingNote, getProviderLabelWithBilling } from '../core/provider-metadata.js'
28
+ import { detectTerminalCapabilities } from '../core/utils.js'
29
+ import { truncateAnsiWidth } from './render-helpers.js'
28
30
 
29
31
  export function createOverlayRenderers(state, deps) {
30
32
  const {
@@ -725,29 +727,48 @@ export function createOverlayRenderers(state, deps) {
725
727
  // ─── Command palette renderer ──────────────────────────────────────────────
726
728
  // 📖 renderCommandPalette draws a centered floating modal over the live table.
727
729
  // 📖 Supports hierarchical categories with expand/collapse and rich colors.
730
+ // 📖 Degrades gracefully on limited terminals (issue #169): width is clamped to
731
+ // 📖 the screen, long descriptions truncate with an ellipsis, and colors are
732
+ // 📖 dropped entirely when the terminal cannot render them.
728
733
  function renderCommandPalette() {
729
734
  const terminalRows = state.terminalRows || 24
730
735
  const terminalCols = state.terminalCols || 80
731
- const panelWidth = Math.max(52, Math.min(100, terminalCols - 8))
732
- const panelInnerWidth = Math.max(32, panelWidth - 4)
736
+ const caps = detectTerminalCapabilities({ env: process.env, cols: terminalCols, rows: terminalRows, isTTY: true })
737
+
738
+ // 📖 Style set: identity wrappers when color is unsupported so the palette
739
+ // 📖 renders as plain default text (no SGR noise on dumb/mono terminals).
740
+ const id = (x) => x
741
+ const sty = caps.colorSupported ? themeColors : {
742
+ dim: id,
743
+ infoBold: id,
744
+ headerBold: id,
745
+ textBold: id,
746
+ accentBold: id,
747
+ bgCursor: id,
748
+ provider: (_key, text) => text,
749
+ tier: (_tier, text) => text,
750
+ overlayBgCommandPalette: id,
751
+ }
752
+
753
+ // 📖 Width budget: never wider than the screen. Outer = inner content + 2 cols
754
+ // 📖 padding per side. The floors shrink with the terminal so outer width stays
755
+ // 📖 <= cols for any terminal >= 20 columns (below that the whole TUI is unusable).
756
+ const panelWidth = Math.min(100, Math.max(16, terminalCols - 8))
757
+ const panelInnerWidth = Math.max(10, panelWidth - 4)
733
758
  const panelPad = 2
734
759
  const panelOuterWidth = panelWidth + (panelPad * 2)
735
- const headerRowCount = 4
736
- const bodyRows = Math.max(8, Math.min(18, terminalRows - 12))
737
-
738
- const truncatePlain = (text, width) => {
739
- if (width <= 1) return ''
740
- if (displayWidth(text) <= width) return text
741
- if (width <= 2) return text.slice(0, width)
742
- return text.slice(0, width - 1) + '…'
743
- }
760
+
761
+ // 📖 Height budget: compact terminals (cols<90 or rows<24) drop the decorative
762
+ // 📖 blank frame and merge footer hints so bodyRows always leaves room for
763
+ // 📖 header + footer inside the screen.
764
+ const bodyRows = Math.max(1, Math.min(18, terminalRows - (caps.compact ? 5 : 10)))
744
765
 
745
766
  const highlightMatch = (label, positions = []) => {
746
767
  if (!Array.isArray(positions) || positions.length === 0) return label
747
768
  const posSet = new Set(positions)
748
769
  let out = ''
749
770
  for (let i = 0; i < label.length; i++) {
750
- out += posSet.has(i) ? themeColors.accentBold(label[i]) : label[i]
771
+ out += posSet.has(i) ? sty.accentBold(label[i]) : label[i]
751
772
  }
752
773
  return out
753
774
  }
@@ -756,64 +777,78 @@ export function createOverlayRenderers(state, deps) {
756
777
  const panelLines = []
757
778
  const cursorLineByRow = {}
758
779
 
780
+ // 📖 Compose one entry row inside `budget` display columns: prefix and label are
781
+ // 📖 prioritized, shortcut is kept when it fits, description is truncated last.
782
+ const composeRow = (budget, { prefixPlain, prefixStyled, label, labelStyled, shortcut, description }) => {
783
+ const prefixW = displayWidth(prefixPlain)
784
+ const avail = Math.max(0, budget - prefixW)
785
+ const shortcutW = shortcut ? displayWidth(` (${shortcut})`) : 0
786
+ // 📖 Keep at least 6 columns of label so the entry name stays readable.
787
+ const labelMax = avail - shortcutW
788
+ let usedShortcut = shortcut
789
+ let labelPlain = label
790
+ if (labelMax >= 6) {
791
+ labelPlain = truncateAnsiWidth(label, labelMax, { ellipsis: '' })
792
+ } else {
793
+ usedShortcut = ''
794
+ labelPlain = truncateAnsiWidth(label, Math.max(1, avail), { ellipsis: '' })
795
+ }
796
+ let row = `${prefixStyled}${labelStyled(labelPlain)}`
797
+ if (usedShortcut) row += sty.dim(` (${usedShortcut})`)
798
+ if (description) {
799
+ const descRoom = budget - prefixW - displayWidth(labelPlain) - (usedShortcut ? displayWidth(` (${usedShortcut})`) : 0) - 3
800
+ // 📖 Below 8 columns a truncated description reads as garbage; drop it instead.
801
+ if (descRoom >= 8) row += sty.dim(` - ${truncateAnsiWidth(description, descRoom, { ellipsis: '…' })}`)
802
+ }
803
+ return truncateAnsiWidth(row, budget)
804
+ }
805
+
759
806
  if (allResults.length === 0) {
760
- panelLines.push(themeColors.dim(' No commands found. Try a different search.'))
807
+ panelLines.push(sty.dim(' No commands found. Try a different search.'))
761
808
  } else {
762
809
  for (let idx = 0; idx < allResults.length; idx++) {
763
810
  const entry = allResults[idx]
764
811
  const isCursor = idx === state.commandPaletteCursor
765
-
812
+
766
813
  const indent = ' '.repeat(entry.depth || 0)
767
814
  const expandIndicator = entry.hasChildren
768
- ? (entry.isExpanded ? themeColors.infoBold('▼') : themeColors.dim('▶'))
769
- : themeColors.dim('•')
770
-
771
- // 📖 Only use icon from entry, label should NOT include emoji
772
- const iconPrefix = entry.icon ? `${entry.icon} ` : ''
773
- const plainLabel = truncatePlain(entry.label, panelInnerWidth - indent.length - iconPrefix.length - 4)
774
- const label = entry.matchPositions ? highlightMatch(plainLabel, entry.matchPositions) : plainLabel
775
-
776
- let rowLine
777
- if (entry.type === 'category') {
778
- rowLine = `${indent}${expandIndicator} ${iconPrefix}${themeColors.headerBold(label)}`
779
- } else if (entry.type === 'subcategory') {
780
- rowLine = `${indent}${expandIndicator} ${iconPrefix}${themeColors.textBold(label)}`
781
- } else if (entry.type === 'page') {
782
- // 📖 Pages are at root level with icon + label + shortcut + description
783
- const shortcut = entry.shortcut ? themeColors.dim(` (${entry.shortcut})`) : ''
784
- const description = entry.description ? themeColors.dim(` — ${entry.description}`) : ''
785
- rowLine = `${expandIndicator} ${iconPrefix}${themeColors.textBold(label)}${shortcut}${description}`
786
- } else if (entry.type === 'action') {
787
- // 📖 Actions are at root level with icon + label + shortcut + description
788
- const shortcut = entry.shortcut ? themeColors.dim(` (${entry.shortcut})`) : ''
789
- const description = entry.description ? themeColors.dim(` ${entry.description}`) : ''
790
- rowLine = `${expandIndicator} ${iconPrefix}${themeColors.textBold(label)}${shortcut}${description}`
791
- } else {
792
- // 📖 Regular commands in submenus
793
- const shortcut = entry.shortcut ? themeColors.dim(` (${entry.shortcut})`) : ''
794
- const description = entry.description ? themeColors.dim(` — ${entry.description}`) : ''
795
- // 📖 Color tiers and providers
796
- let coloredLabel = label
797
- let prefixWithIcon = iconPrefix
798
-
799
- if (entry.providerKey && !entry.icon) {
800
- // 📖 Model filter: add provider icon
801
- const providerIcon = '🏢'
802
- prefixWithIcon = `${providerIcon} `
803
- coloredLabel = themeColors.provider(entry.providerKey, label, { bold: false })
804
- } else if (entry.tier) {
805
- coloredLabel = themeColors.tier(entry.tier, label)
806
- } else if (entry.providerKey) {
807
- coloredLabel = themeColors.provider(entry.providerKey, label, { bold: false })
808
- }
809
-
810
- rowLine = `${indent} ${expandIndicator} ${prefixWithIcon}${coloredLabel}${shortcut}${description}`
811
- }
815
+ ? (entry.isExpanded ? sty.infoBold('▼') : sty.dim('▶'))
816
+ : sty.dim('•')
817
+
818
+ // 📖 Only use icon from entry, label should NOT include emoji.
819
+ // 📖 Model filter rows without an icon keep their provider building glyph.
820
+ const iconPrefix = entry.icon
821
+ ? `${entry.icon} `
822
+ : (entry.providerKey && entry.type === 'command' ? '🏢 ' : '')
823
+ // 📖 Submenu commands keep their extra 2-space indent inside the tree.
824
+ const rowGap = entry.type === 'command' ? ' ' : ''
825
+ const prefixPlain = `${indent}${rowGap} ${iconPrefix}`
826
+ const prefixStyled = `${indent}${rowGap}${expandIndicator} ${iconPrefix}`
827
+
828
+ // 📖 Width of prefixPlain and prefixStyled match (indicator is 1 column).
829
+ const rowLine = composeRow(panelInnerWidth, {
830
+ prefixPlain,
831
+ prefixStyled,
832
+ label: entry.label,
833
+ labelStyled: (plainLabel) => {
834
+ const highlighted = entry.matchPositions ? highlightMatch(plainLabel, entry.matchPositions) : plainLabel
835
+ if (entry.type === 'category') return sty.headerBold(highlighted)
836
+ if (entry.type === 'subcategory') return sty.textBold(highlighted)
837
+ if (entry.providerKey && !entry.icon) return sty.provider(entry.providerKey, highlighted, { bold: false })
838
+ if (entry.tier) return sty.tier(entry.tier, highlighted)
839
+ if (entry.providerKey) return sty.provider(entry.providerKey, highlighted, { bold: false })
840
+ return sty.textBold(highlighted)
841
+ },
842
+ shortcut: entry.shortcut || '',
843
+ description: entry.description || '',
844
+ })
812
845
 
813
846
  cursorLineByRow[idx] = panelLines.length
814
-
847
+
815
848
  if (isCursor) {
816
- panelLines.push(themeColors.bgCursor(rowLine))
849
+ // 📖 Without color the bg highlight is invisible, so mark the cursor row
850
+ // 📖 with an ASCII '>' (safe on dumb/mono consoles where ❯ may not exist).
851
+ panelLines.push(caps.colorSupported ? sty.bgCursor(rowLine) : `> ${rowLine}`)
817
852
  } else {
818
853
  panelLines.push(rowLine)
819
854
  }
@@ -827,42 +862,43 @@ export function createOverlayRenderers(state, deps) {
827
862
  panelLines.length,
828
863
  bodyRows
829
864
  )
830
- const { visible, offset } = sliceOverlayLines(panelLines, state.commandPaletteScrollOffset, bodyRows)
831
- state.commandPaletteScrollOffset = offset
865
+ const { visible } = sliceOverlayLines(panelLines, state.commandPaletteScrollOffset, bodyRows)
832
866
 
833
867
  const query = state.commandPaletteQuery || ''
834
868
  const queryWithCursor = query.length > 0
835
- ? `${query}${themeColors.accentBold('▏')}`
836
- : themeColors.accentBold('▏') + themeColors.dim(' Search commands…')
837
-
838
- const headerLines = []
839
- const title = themeColors.headerBold('⚡️ Command Palette')
840
- const titleLeft = ` ${title}`
841
- const titleRight = themeColors.dim('Esc')
842
- const titleWidth = Math.max(1, panelInnerWidth - 1 - displayWidth('Esc'))
843
- headerLines.push(`${padEndDisplay(titleLeft, titleWidth)} ${titleRight}`)
844
- headerLines.push(` ${padEndDisplay(`> ${queryWithCursor}`, panelInnerWidth)}`)
845
- headerLines.push(themeColors.dim(` ${''.repeat(Math.max(1, panelInnerWidth))}`))
846
-
847
- const footerLines = [
848
- themeColors.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth))}`),
849
- ` ${padEndDisplay(themeColors.dim('↵ Select • ← → Expand'), panelInnerWidth)}`,
850
- ` ${padEndDisplay(themeColors.dim('↑↓ Navigate • Type search'), panelInnerWidth)}`,
869
+ ? `${query}${sty.accentBold('▏')}`
870
+ : sty.accentBold('▏') + sty.dim(' Search commands…')
871
+
872
+ // 📖 Header: title left, Esc hint right, search input, separator. Each piece is
873
+ // 📖 width-budgeted so the header can never push past the panel on tiny screens.
874
+ const escHint = sty.dim('Esc')
875
+ const titleBudget = Math.max(1, panelInnerWidth - 1 - displayWidth('Esc'))
876
+ const queryBudget = Math.max(1, panelInnerWidth - 1)
877
+ const headerLines = [
878
+ ` ${padEndDisplay(truncateAnsiWidth(sty.headerBold('⚡️ Command Palette'), titleBudget, { ellipsis: '…' }), titleBudget)} ${escHint}`,
879
+ ` ${padEndDisplay(truncateAnsiWidth(`> ${queryWithCursor}`, queryBudget, { ellipsis: '' }), queryBudget)}`,
880
+ sty.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth - 1))}`),
851
881
  ]
852
882
 
883
+ const sepLine = sty.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth - 1))}`)
884
+ const footerLines = caps.compact
885
+ ? [sepLine, ` ${sty.dim(truncateAnsiWidth('↵ Select • ↑↓ Navigate • Type search', panelInnerWidth - 1))}`]
886
+ : [
887
+ sepLine,
888
+ ` ${padEndDisplay(sty.dim('↵ Select • ← → Expand'), panelInnerWidth - 1)}`,
889
+ ` ${padEndDisplay(sty.dim('↑↓ Navigate • Type search'), panelInnerWidth - 1)}`,
890
+ ]
891
+
892
+ // 📖 Compact mode skips the decorative blank frame above/below the panel.
893
+ // 📖 truncateAnsiWidth is a final safety clamp: rows are already budgeted to
894
+ // 📖 panelInnerWidth, this only catches emoji width miscounts so a line can
895
+ // 📖 never wrap and corrupt the frame on an 80-col console.
896
+ const blankFrameRows = caps.compact ? 0 : 2
853
897
  const allPanelLines = [...headerLines, ...visible, ...footerLines]
854
-
855
- while (allPanelLines.length < bodyRows + headerRowCount + 3) {
856
- allPanelLines.splice(headerLines.length + visible.length, 0, ` ${' '.repeat(panelInnerWidth)}`)
857
- }
858
-
859
- const blankPaddedLine = ' '.repeat(panelOuterWidth)
860
898
  const paddedPanelLines = [
861
- blankPaddedLine,
862
- blankPaddedLine,
863
- ...allPanelLines.map((line) => `${' '.repeat(panelPad)}${padEndDisplay(line, panelWidth)}${' '.repeat(panelPad)}`),
864
- blankPaddedLine,
865
- blankPaddedLine,
899
+ ...Array.from({ length: blankFrameRows }, () => ' '.repeat(panelOuterWidth)),
900
+ ...allPanelLines.map((line) => `${' '.repeat(panelPad)}${padEndDisplay(truncateAnsiWidth(line, panelWidth), panelWidth)}${' '.repeat(panelPad)}`),
901
+ ...Array.from({ length: blankFrameRows }, () => ' '.repeat(panelOuterWidth)),
866
902
  ]
867
903
 
868
904
  const panelHeight = paddedPanelLines.length
@@ -870,8 +906,8 @@ export function createOverlayRenderers(state, deps) {
870
906
  const left = Math.max(1, Math.floor((terminalCols - panelOuterWidth) / 2) + 1)
871
907
 
872
908
  // 📖 Mouse support: record CP layout so clicks inside the modal can select items.
873
- // 📖 Body rows start after 2 blank-padding lines + headerLines (3).
874
- const bodyStartRow = top + 2 + headerLines.length // 📖 1-based terminal row of first body line
909
+ // 📖 Body rows start after the blank frame + headerLines (3).
910
+ const bodyStartRow = top + blankFrameRows + headerLines.length // 📖 1-based terminal row of first body line
875
911
  overlayLayout.commandPaletteCursorToLine = { ...cursorLineByRow }
876
912
  overlayLayout.commandPaletteScrollOffset = state.commandPaletteScrollOffset
877
913
  overlayLayout.commandPaletteBodyStartRow = bodyStartRow
@@ -883,11 +919,14 @@ export function createOverlayRenderers(state, deps) {
883
919
 
884
920
  const tintedLines = paddedPanelLines.map((line) => {
885
921
  const padded = padEndDisplay(line, panelOuterWidth)
886
- return themeColors.overlayBgCommandPalette(padded)
922
+ return sty.overlayBgCommandPalette(padded)
887
923
  })
888
924
 
925
+ // 📖 Emit each row from column 1 and clear to end of line so stale table cells
926
+ // 📖 in the margins beside the panel can never bleed through (the artifacts
927
+ // 📖 reported in issue #169). \x1b[K never wraps: panel right edge <= cols.
889
928
  return tintedLines
890
- .map((line, idx) => `\x1b[${top + idx};${left}H${line}`)
929
+ .map((line, idx) => `\x1b[${top + idx};1H${' '.repeat(left - 1)}${line}\x1b[K`)
891
930
  .join('')
892
931
  }
893
932
 
@@ -963,7 +1002,9 @@ export function createOverlayRenderers(state, deps) {
963
1002
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
964
1003
  lines.push(` ${key('Ctrl+A')} AI Speed Test ${hint('(benchmark selected model → time + TPS)')}`)
965
1004
  lines.push(` ${key('Ctrl+U')} Global AI Speed Test ${hint('(benchmark all models; Settings can auto-run it on startup)')}`)
966
- lines.push(` ${key('Ctrl+Shift+P')} Probe 404 Models ${hint('(test all configured models; auto-hide broken 404/410)')}`)
1005
+ lines.push(` ${key('Shift+P')} Re-probe failed rows ${hint('(retry only rows showing auth fail / 429 / 404 / timeout - not the whole list)')}`)
1006
+ lines.push(` ${key('Ctrl+Shift+P')} Probe All Models ${hint('(test all configured models; auto-hide broken 404/410)')}`)
1007
+ lines.push(` ${key('Space')} Expand selected row ${hint('(2-line detail: provider, endpoint, full model id; Space again or move to collapse)')}`)
967
1008
  lines.push(` ${key('E')} Cycle filter mode ${hint('(Normal → Configured only → Usable only)')}`)
968
1009
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → π Pi → 🪼 jcode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → ⚡ Amp)')}`)
969
1010
  lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(1️⃣2️⃣3️⃣ = router fallback order, capped at 🔟)')}`)
@@ -52,10 +52,11 @@ import chalk from 'chalk'
52
52
  import { OVERLAY_PANEL_WIDTH, TABLE_FIXED_LINES, TABLE_HEADER_LINES, TABLE_FOOTER_LINES } from '../core/constants.js'
53
53
  import { sortResults } from '../core/utils.js'
54
54
 
55
- // 📖 stripAnsi: Remove ANSI color/control sequences to estimate visible text width before padding.
56
- // 📖 Strips CSI sequences (SGR colors) and OSC sequences (hyperlinks).
55
+ // 📖 stripAnsi: Remove ANSI control sequences to estimate visible text width before padding.
56
+ // 📖 Strips ALL CSI sequences (SGR colors, \x1b[K clear, cursor moves) and OSC
57
+ // 📖 sequences (hyperlinks) - every escape is zero-width for measurement purposes.
57
58
  export function stripAnsi(input) {
58
- return String(input).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
59
+ return String(input).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
59
60
  }
60
61
 
61
62
  // 📖 fadedRow: Multiply every 24-bit RGB channel inside an ANSI-colored string by `factor`
@@ -158,6 +159,44 @@ export function padEndDisplay(str, width) {
158
159
  return str + ' '.repeat(need)
159
160
  }
160
161
 
162
+ // 📖 truncateAnsiWidth: Hard-clamp ANY string (plain or ANSI-styled) to a max
163
+ // 📖 display width, appending an ellipsis when content is cut. ANSI escape
164
+ // 📖 sequences are preserved (they cost 0 columns) so styled overlay rows can be
165
+ // 📖 clamped as a final safety pass without breaking colors. A reset (\x1b[0m) is
166
+ // 📖 inserted before the ellipsis so a cut inside a colored run cannot bleed the
167
+ // 📖 color into the ellipsis or the following cells.
168
+ export function truncateAnsiWidth(input, maxWidth, { ellipsis = '…' } = {}) {
169
+ const text = String(input)
170
+ const budget = Math.max(0, Math.floor(maxWidth) || 0)
171
+ if (budget === 0) return ''
172
+ if (displayWidth(text) <= budget) return text
173
+
174
+ // 📖 Split into ANSI sequences and visible characters so escapes are kept verbatim.
175
+ // 📖 Capturing group makes String.split keep the separators (ANSI tokens) in the result.
176
+ const ANSI_TOKEN = /(\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x1b]*(?:\x1b\\|\u0007)?|\x1b.)/g
177
+ const tokens = text.split(ANSI_TOKEN).filter((t) => t !== '')
178
+ let out = ''
179
+ let w = 0
180
+ const ellWidth = displayWidth(ellipsis)
181
+ // 📖 Reserve room for the ellipsis, but never let the reserve swallow the whole budget.
182
+ const cutBudget = Math.max(0, budget - Math.min(ellWidth, Math.max(0, budget - 1)))
183
+
184
+ let cut = false
185
+ for (const token of tokens) {
186
+ const isAnsi = token.startsWith('\x1b')
187
+ if (isAnsi) { out += token; continue }
188
+ // 📖 Walk visible characters code point by code point (emoji are 2 columns).
189
+ for (const ch of token) {
190
+ const cw = displayWidth(ch)
191
+ if (w + cw > cutBudget) { cut = true; break }
192
+ out += ch
193
+ w += cw
194
+ }
195
+ if (cut) break
196
+ }
197
+ return out + '\x1b[0m' + ellipsis
198
+ }
199
+
161
200
  // 📖 tintOverlayLines: Tint overlay lines with a terminal width so the background is clearly visible.
162
201
  // 📖 Applies bgColor to each line and pads to terminalCols for full-width panel look.
163
202
  // 📖 If terminalCols is not provided, falls back to OVERLAY_PANEL_WIDTH for compatibility.
@@ -162,6 +162,8 @@ export const PROVIDER_COLOR = new Proxy({}, {
162
162
  * 'models.dev': number,
163
163
  * },
164
164
  * modelsDevCacheCached?: boolean, // t5: whether the models.dev cache is still within TTL
165
+ * expandedRowKey?: string|null, // 📖 issue #168: Space-expanded row ("provider/modelId"), renders a 2-line detail card under the cursor row
166
+ * actionErrorMsg?: string|null, // 📖 issue #168: non-fatal command/probe failure shown as a footer chip instead of crashing
165
167
  * quota?: Record<string, { // t2: live quota from response headers
166
168
  * remaining: number, limit: number, percent: number,
167
169
  * source: 'header'|'endpoint', lastUpdated: number, windowType?: string,
@@ -212,6 +214,8 @@ export function renderTable({
212
214
  probeTotal = 0,
213
215
  probeCompleted = 0,
214
216
  probeHiddenCount = 0,
217
+ expandedRowKey = null, // 📖 issue #168: "providerKey/modelId" of the Space-expanded row (null = collapsed)
218
+ actionErrorMsg = null, // 📖 issue #168: non-fatal command/probe failure for the footer chip
215
219
  probeCacheHits = 0,
216
220
  probeCacheMisses = 0,
217
221
  probeCacheBrokenHidden = 0,
@@ -650,9 +654,37 @@ export function renderTable({
650
654
  const hasCustomFilter = typeof customTextFilter === 'string' && customTextFilter.trim().length > 0
651
655
  const hasReleaseFooter = typeof lastReleaseDate === 'string' && lastReleaseDate.trim().length > 0
652
656
  const extraFooterLines = (versionStatus.isOutdated ? 1 : 0) + (hasCustomFilter ? 1 : 0) + (hasReleaseFooter ? 1 : 0)
657
+
658
+ // 📖 Expanded row detail (issue #168): Space toggles a 2-line card under the
659
+ // 📖 cursor row showing provider + model specifics that tight columns truncate.
660
+ // 📖 The card only renders when the expanded key still matches the cursor row
661
+ // 📖 (any cursor move clears state.expandedRowKey), and the viewport reserves
662
+ // 📖 2 lines up front so the footer never gets pushed off-screen.
663
+ const EXPANDED_DETAIL_LINES = 2
664
+ const cursorRow = cursor !== null ? sorted[cursor] : null
665
+ const expansionActive = !!expandedRowKey && !!cursorRow
666
+ && `${cursorRow.providerKey}/${cursorRow.modelId}` === expandedRowKey
653
667
  const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, {
654
- extraFixedLines: extraFooterLines,
668
+ extraFixedLines: extraFooterLines + (expansionActive ? EXPANDED_DETAIL_LINES : 0),
655
669
  })
670
+
671
+ // 📖 clipPlainWidth: Clip a PLAIN string (no ANSI codes) to a max display
672
+ // 📖 width, appending an ellipsis when truncated. Used by the Space detail
673
+ // 📖 card so long endpoint URLs and model IDs degrade gracefully on narrow
674
+ // 📖 terminals instead of wrapping the table.
675
+ const clipPlainWidth = (text, max) => {
676
+ if (max <= 0) return ''
677
+ const chars = [...String(text)]
678
+ let w = 0
679
+ let out = ''
680
+ for (const ch of chars) {
681
+ const cw = displayWidth(ch)
682
+ if (w + cw > max - 1) return out + '…'
683
+ out += ch
684
+ w += cw
685
+ }
686
+ return out
687
+ }
656
688
  const paintSweScore = (score, paddedText) => {
657
689
  if (score >= 70) return chalk.bold.rgb(...getTierRgb('S+'))(paddedText)
658
690
  if (score >= 60) return chalk.bold.rgb(...getTierRgb('S'))(paddedText)
@@ -1042,6 +1074,30 @@ export function renderTable({
1042
1074
  renderedRow = row
1043
1075
  }
1044
1076
  lines.push(isUnusable ? fadedRow(renderedRow, 0.8) : renderedRow)
1077
+
1078
+ // 📖 Expanded detail card (issue #168): 2 lines under the selected row so
1079
+ // 📖 provider and model specifics stay readable even when tight columns
1080
+ // 📖 truncate them. Plain text is built first, clipped to the terminal
1081
+ // 📖 width, then colorized (same pattern as the table header).
1082
+ if (isCursor && expansionActive) {
1083
+ const maxDetailWidth = Math.max(0, (terminalCols || 80) - 4)
1084
+ const keyText = r.hasApiKey ? 'key configured' : 'no key'
1085
+ const endpoint = sources[r.providerKey]?.url ?? 'unknown endpoint'
1086
+ const detailProvider = clipPlainWidth(
1087
+ ` ↳ ${providerName} (${r.providerKey}) · ${keyText} · ${endpoint}`,
1088
+ maxDetailWidth
1089
+ )
1090
+ const lastMsText = latestPing
1091
+ ? (typeof latestPing.ms === 'number' ? `${latestPing.ms}ms` : String(latestPing.ms))
1092
+ : 'no ping yet'
1093
+ const lastCodeText = latestPing ? `HTTP ${latestPing.code}` : ''
1094
+ const detailModel = clipPlainWidth(
1095
+ ` ↳ ${r.modelId} · tier ${r.tier} · SWE ${r.sweScore ?? '-'} · ctx ${r.ctx ?? '-'} · last ${lastMsText}${lastCodeText ? ` ${lastCodeText}` : ''} · added ${r.addedDate ?? '-'}`,
1096
+ maxDetailWidth
1097
+ )
1098
+ lines.push(themeColors.provider(r.providerKey, detailProvider))
1099
+ lines.push(themeColors.dim(detailModel))
1100
+ }
1045
1101
  }
1046
1102
 
1047
1103
  // 📖 Mouse support: record the 1-based terminal row range of model data rows.
@@ -1214,11 +1270,15 @@ export function renderTable({
1214
1270
  const speedTestLabel = chalk.bgRgb(...currentPalette().badgeSpeedTestBg).rgb(...currentPalette().badgeSpeedTestFg).bold(' NEW ⭐️ Ctrl+A 🤖 AI Speed Test ')
1215
1271
  const globalBenchmarkLabel = chalk.bgRgb(...currentPalette().badgeBenchmarkBg).rgb(...currentPalette().badgeBenchmarkFg).bold(' NEW Ctrl+U : Global AI Speed Test (Uses a lot of requests!) ')
1216
1272
 
1217
- // 📖 Probe badge: show progress when 404 probe is running or recently completed
1273
+ // 📖 Probe badge: show progress when 404 probe is running or recently completed.
1274
+ // 📖 Bar width is clamped to 0-20 cells (issue #168 kick-out fix): a transient
1275
+ // 📖 counter hiccup must never reach String.repeat with a negative count - a
1276
+ // 📖 RangeError here crashes the render interval and kills the whole TUI.
1218
1277
  let probeLabel = ''
1219
1278
  if (probeRunning) {
1220
1279
  const pct = probeTotal > 0 ? Math.round((probeCompleted / probeTotal) * 100) : 0
1221
- const bar = '█'.repeat(Math.floor(pct / 5)) + '░'.repeat(20 - Math.floor(pct / 5))
1280
+ const filled = Math.max(0, Math.min(20, Math.floor(pct / 5)))
1281
+ const bar = '█'.repeat(filled) + '░'.repeat(20 - filled)
1222
1282
  probeLabel = chalk.bgRgb(180, 40, 40).rgb(255, 255, 255).bold(` 🔍 Probe ${bar} ${probeCompleted}/${probeTotal} `)
1223
1283
  } else if (probeHiddenCount > 0 && probeTotal > 0) {
1224
1284
  probeLabel = chalk.bgRgb(120, 60, 60).rgb(255, 200, 200).bold(` 🔍 Probe done: ${probeHiddenCount} broken model${probeHiddenCount > 1 ? 's' : ''} hidden `)
@@ -1308,8 +1368,16 @@ export function renderTable({
1308
1368
  }
1309
1369
  }
1310
1370
 
1371
+ // 📖 Action error chip (issue #168): non-fatal failures from command palette
1372
+ // 📖 actions or the 404 probe land here instead of crashing the TUI. Red chip
1373
+ // 📖 so it reads as an error, auto-expires (app.js passes null when stale).
1374
+ let actionErrorLabel = ''
1375
+ if (actionErrorMsg) {
1376
+ actionErrorLabel = chalk.bgRgb(170, 30, 30).rgb(255, 220, 220).bold(` ⚠ ${actionErrorMsg} `)
1377
+ }
1378
+
1311
1379
  // 📖 Line 3: Speed Test + Global Benchmark + Probe + Probe-cache + Paused-providers + Enrichment + Quota + Last release
1312
- if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || pausedProvidersLabel || enrichmentLabel || quotaLabel) {
1380
+ if (releaseLabel || speedTestLabel || globalBenchmarkLabel || probeLabel || probeCacheLabel || pausedProvidersLabel || enrichmentLabel || quotaLabel || actionErrorLabel) {
1313
1381
  const parts = [
1314
1382
  { text: ' ', key: null },
1315
1383
  { text: speedTestLabel, key: 'a' },
@@ -1317,6 +1385,8 @@ export function renderTable({
1317
1385
  { text: globalBenchmarkLabel, key: 'u' },
1318
1386
  { text: probeLabel ? ' ' : '', key: null },
1319
1387
  { text: probeLabel, key: null },
1388
+ { text: actionErrorLabel ? ' ' : '', key: null },
1389
+ { text: actionErrorLabel, key: null },
1320
1390
  { text: probeCacheLabel ? ' ' : '', key: null },
1321
1391
  { text: probeCacheLabel, key: null },
1322
1392
  { text: pausedProvidersLabel ? ' ' : '', key: null },
package/src/tui/theme.js CHANGED
@@ -194,6 +194,7 @@ const PROVIDER_PALETTES = {
194
194
  novita: [255, 185, 120],
195
195
  pollinations: [255, 105, 180],
196
196
  requesty: [100, 149, 255],
197
+ orcarouter: [255, 138, 64],
197
198
  'ollama-cloud': [230, 230, 230],
198
199
  },
199
200
  light: {
@@ -226,6 +227,7 @@ const PROVIDER_PALETTES = {
226
227
  novita: [173, 84, 0],
227
228
  pollinations: [170, 45, 110],
228
229
  requesty: [0, 72, 170],
230
+ orcarouter: [170, 74, 0],
229
231
  'ollama-cloud': [88, 88, 88],
230
232
  },
231
233
  }
@@ -294,12 +294,26 @@ export function createTuiState({
294
294
  globalBenchmarkTotal: 0,
295
295
  globalBenchmarkCompleted: 0,
296
296
 
297
- // 📖 404 Probe state (Ctrl+Shift+P)
297
+ // 📖 404 Probe state (Ctrl+Shift+P / Shift+P)
298
298
  probeRunning: false,
299
299
  probeTotal: 0,
300
300
  probeCompleted: 0,
301
301
  probeHiddenCount: 0,
302
302
 
303
+ // 📖 Space-expanded row (issue #168): the selected row can blow up into a
304
+ // 📖 2-line detail card showing provider + model specifics that tight
305
+ // 📖 columns truncate. Stored as "providerKey/modelId" so the detail always
306
+ // 📖 follows the highlighted row; null = collapsed. Any cursor move clears it.
307
+ expandedRowKey: null,
308
+
309
+ // 📖 Non-fatal action error chip (footer): set when a command palette action
310
+ // 📖 or the 404 probe throws. Part of the issue #168 "kicked out" fix: those
311
+ // 📖 actions used to run fire-and-forget, so any rejection became an unhandled
312
+ // 📖 promise rejection and Node killed the whole TUI process. Failures now
313
+ // 📖 land here and render as a footer chip until actionErrorMsgUntil (epoch ms).
314
+ actionErrorMsg: null,
315
+ actionErrorMsgUntil: 0,
316
+
303
317
  // 📖 Persistent probe-cache (t1): TTL'd cross-session health cache.
304
318
  // 📖 - probeCacheHits / Misses: telemetry counters per session (footer chip).
305
319
  // 📖 - probeCacheBrokenHidden: live count of broken rows currently hidden.