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.
- package/README.md +24 -7
- package/bin/free-coding-models.js +20 -0
- package/changelog/v0.5.82.md +23 -0
- package/changelog/v0.5.83.md +22 -0
- package/package.json +1 -1
- package/sources.js +81 -65
- package/src/core/benchmark.js +1 -1
- package/src/core/config.js +1 -0
- package/src/core/endpoint-installer.js +7 -2
- package/src/core/kilo.js +7 -0
- package/src/core/models-dev-index.js +1 -0
- package/src/core/opencode.js +14 -0
- package/src/core/ping.js +14 -4
- package/src/core/provider-key-tester.js +2 -1
- package/src/core/provider-metadata.js +11 -2
- package/src/core/quota-capabilities.js +1 -0
- package/src/core/router-daemon.js +2 -2
- package/src/core/security.js +132 -65
- package/src/core/sync-set.js +1 -1
- package/src/core/tool-launchers.js +15 -5
- package/src/core/utils.js +88 -1
- package/src/tui/app.js +7 -5
- package/src/tui/cli-help.js +1 -0
- package/src/tui/command-palette.js +3 -2
- package/src/tui/key-handler.js +172 -61
- package/src/tui/overlays.js +135 -94
- package/src/tui/render-helpers.js +42 -3
- package/src/tui/render-table.js +74 -4
- package/src/tui/theme.js +2 -0
- package/src/tui/tui-state.js +15 -1
- package/web/dist/assets/{index-Bb5I0K1-.js → index-CSoKc1qF.js} +3 -3
- package/web/dist/index.html +1 -1
package/src/tui/overlays.js
CHANGED
|
@@ -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
|
|
732
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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) ?
|
|
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(
|
|
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 ?
|
|
769
|
-
:
|
|
770
|
-
|
|
771
|
-
// 📖 Only use icon from entry, label should NOT include emoji
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
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
|
|
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}${
|
|
836
|
-
:
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const
|
|
841
|
-
const
|
|
842
|
-
const
|
|
843
|
-
headerLines
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
...
|
|
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
|
|
874
|
-
const bodyStartRow = top +
|
|
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
|
|
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}
|
|
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('
|
|
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
|
|
56
|
-
// 📖 Strips CSI sequences (SGR colors) and OSC
|
|
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
|
|
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.
|
package/src/tui/render-table.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/tui/tui-state.js
CHANGED
|
@@ -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.
|