free-coding-models 0.5.81 → 0.5.84

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/src/tui/app.js CHANGED
@@ -143,7 +143,6 @@ import {
143
143
  isProviderQuotaPaused,
144
144
  listPausedProviders,
145
145
  } from '../core/provider-cooldown.js'
146
- import { checkConfigSecurity } from '../core/security.js'
147
146
  import { buildCliHelpText } from './cli-help.js'
148
147
  import { detectActiveTheme, THEME_BG_RGB, getTheme, patchThemeBg } from './theme.js'
149
148
 
@@ -271,10 +270,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
271
270
  detectActiveTheme(config.settings?.theme || 'auto')
272
271
 
273
272
  // 📖 Check config file security — warn and offer auto-fix if permissions are too open
274
- const securityCheck = checkConfigSecurity()
275
- if (!securityCheck.wasSecure && !securityCheck.wasFixed) {
276
- // 📖 User declined auto-fix or it failed — continue anyway, just warned
277
- }
273
+ // 📖 (issue #173): this moved to bin/free-coding-models.js BEFORE runApp so the
274
+ // 📖 warning + prompt are visible and resolved before the alternate screen / raw
275
+ // 📖 mode take over. Do not call checkConfigSecurity() here again.
278
276
 
279
277
  // 📖 Apply CLI overrides for settings
280
278
  if (cliArgs.sortColumn) config.settings.sortColumn = cliArgs.sortColumn
@@ -1045,6 +1043,10 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
1045
1043
  probeTotal: state.probeTotal,
1046
1044
  probeCompleted: state.probeCompleted,
1047
1045
  probeHiddenCount: state.probeHiddenCount,
1046
+ // 📖 Space-expanded row (issue #168): "providerKey/modelId" or null
1047
+ expandedRowKey: state.expandedRowKey ?? null,
1048
+ // 📖 Non-fatal action error chip (issue #168): only shown while fresh
1049
+ actionErrorMsg: (state.actionErrorMsgUntil || 0) > Date.now() ? state.actionErrorMsg : null,
1048
1050
  // 📖 Probe-cache (t1): footer chip stats
1049
1051
  probeCacheHits: state.probeCacheHits || 0,
1050
1052
  probeCacheMisses: state.probeCacheMisses || 0,
@@ -49,6 +49,7 @@ const CONFIG_FLAGS = [
49
49
  { flag: '--daemon-stop', description: 'Gracefully stop the FCM Router daemon' },
50
50
  { flag: '--sync-set [name]', description: 'Auto-discover and live-probe models into a router set' },
51
51
  { flag: '--config-dir <dir>', description: 'Store config.json + backups/ in <dir> (e.g. --config-dir ~/.config/free-coding-models for the XDG layout)' },
52
+ { flag: '--fix-permissions, --yes, -y', description: 'Auto-fix insecure config file permissions (chmod 600) without prompting; safe for scripts and CI' },
52
53
  { flag: '--no-telemetry', description: 'Disable anonymous telemetry for this run' },
53
54
  { flag: '--help, -h', description: 'Print this help and exit' },
54
55
  ]
@@ -85,7 +86,9 @@ export function buildHowTheRouterWorksLines({ chalk = null, indent = '' } = {})
85
86
  lines.push(body('Point any OpenAI-compatible client at http://localhost:19280/v1'))
86
87
  lines.push(body('with model: "fcm". The daemon picks the healthiest model in'))
87
88
  lines.push(body('your active set and forwards the request — with automatic'))
88
- lines.push(body('failover if the first model 429s or 5xxs.'))
89
+ lines.push(body('failover if the first model 429s or 5xxs. Failover prefers the'))
90
+ lines.push(body('same model family on another provider (toggled per set as'))
91
+ lines.push(body('Family failover on the Router Dashboard) before switching family.'))
89
92
  lines.push('')
90
93
 
91
94
  lines.push(header('2. The pre-prompt (system message)'))
@@ -45,7 +45,7 @@ const TOOL_MODE_DESCRIPTIONS = {
45
45
  hermes: 'Launch Hermes Agent with the selected model.',
46
46
  'continue': 'Launch Continue CLI with the selected model.',
47
47
  cline: 'Launch Cline CLI with the selected model.',
48
- caveman: 'Caveman Code — token-efficient coding agent (launch with Caveman tool only).',
48
+ caveman: 'Caveman Code - token-efficient coding agent (launch with Caveman tool only).',
49
49
  jcode: 'Launch jcode coding agent with the selected model.',
50
50
  }
51
51
 
@@ -201,7 +201,8 @@ const BASE_COMMAND_TREE = [
201
201
  },
202
202
  { id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '🌗', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
203
203
  { id: 'action-reset-view', label: 'Reset view', shortcut: 'N', icon: '\u{1F504}', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
204
- { id: 'action-probe-404', label: 'Probe 404 Models', shortcut: 'Ctrl+Shift+P', icon: '\u{1F50D}', description: 'Test all configured models for 404/410 errors. Auto-hides broken models.', keywords: ['probe', '404', 'broken', 'dead', 'health', 'check', 'test', 'verify'] },
204
+ { id: 'action-probe-failed', label: 'Re-probe Failed Models', shortcut: 'Shift+P', icon: '\u{1F50D}', description: 'Re-test only rows showing errors (auth fail / 429 / 404 / timeout) - not the whole list.', keywords: ['probe', 'failed', 'retry', '429', 'auth', 'error', 'broken', 'rescan', 'health'] },
205
+ { id: 'action-probe-404', label: 'Probe All Models (404)', shortcut: 'Ctrl+Shift+P', icon: '\u{1F50D}', description: 'Test all configured models for 404/410 errors. Auto-hides broken models.', keywords: ['probe', '404', 'broken', 'dead', 'health', 'check', 'test', 'verify', 'all'] },
205
206
  { id: 'action-toggle-auto-hide-broken', label: 'Toggle Auto-hide Broken Models', icon: '\u{1F6A9}', description: 'Enable/disable auto-hiding of 404/410 models. Unhides all when disabled.', keywords: ['auto', 'hide', 'broken', '404', 'models', 'toggle', 'probe'] },
206
207
  ],
207
208
  },
@@ -51,6 +51,7 @@ import {
51
51
  handlePlaygroundKeypress,
52
52
  } from '../core/playground.js'
53
53
  import { benchmarkModel } from '../core/benchmark.js'
54
+ import { selectProbeFailedRows } from '../core/utils.js'
54
55
  import { isPackageDevMode } from '../core/updater.js'
55
56
  import {
56
57
  runProviderKeyTest,
@@ -171,6 +172,11 @@ export function createKeyHandler(ctx) {
171
172
 
172
173
  let userSelected = null
173
174
 
175
+ // 📖 Monotonic token for 404-probe runs (issue #168). Each probe start bumps it
176
+ // 📖 so stale reset timers from a previous run can detect they no longer own
177
+ // 📖 the probe counters and skip their reset instead of clobbering live values.
178
+ let probeRunSeq = 0
179
+
174
180
  function resetToolInstallPrompt() {
175
181
  state.toolInstallPromptOpen = false
176
182
  state.toolInstallPromptCursor = 0
@@ -967,94 +973,143 @@ export function createKeyHandler(ctx) {
967
973
  return Promise.all(workers).then(() => results)
968
974
  }
969
975
 
970
- // 📖 runBrokenModelProbe: Probe all configured models for 404/broken endpoints.
976
+ // 📖 setActionNotice: Surface a non-fatal error/info message as a footer chip.
977
+ // 📖 Part of the issue #168 "kicked out" fix: palette commands and the 404 probe
978
+ // 📖 used to run fire-and-forget, so any rejection became an unhandled promise
979
+ // 📖 rejection and Node terminated the whole TUI process. Failures now land here
980
+ // 📖 and the app keeps running. Auto-expires after `ms` milliseconds.
981
+ function setActionNotice(st, msg, ms = 8000) {
982
+ st.actionErrorMsg = String(msg).slice(0, 120)
983
+ st.actionErrorMsgUntil = Date.now() + ms
984
+ }
985
+
986
+ // 📖 runBrokenModelProbe: Probe models for 404/broken endpoints.
971
987
  // 📖 Sends a real chat-completion request to each model with an API key.
972
988
  // 📖 Models returning 404 or 410 are auto-hidden when setting is enabled.
973
989
  // 📖 Results flash live in the TUI table — models flip status in real time.
974
- async function runBrokenModelProbe(state) {
990
+ // 📖 opts.failedOnly (Shift+P, issue #168): probe ONLY rows currently showing
991
+ // 📖 errors (auth fail / 429 / 404 / timeout) so the user can retry 1-2 flaky
992
+ // 📖 providers without probing (and burning quota on) the whole list.
993
+ async function runBrokenModelProbe(state, { failedOnly = false } = {}) {
975
994
  if (state.probeRunning) return
976
995
 
977
996
  // 📖 Only probe models where the user has an API key configured
978
- const probeable = state.results.filter(r => {
997
+ const candidates = failedOnly ? selectProbeFailedRows(state.results) : state.results
998
+ const probeable = candidates.filter(r => {
979
999
  const apiKey = getApiKey(state.config, r.providerKey)
980
1000
  return !!apiKey
981
1001
  })
982
1002
 
983
- if (probeable.length === 0) return
1003
+ if (probeable.length === 0) {
1004
+ // 📖 Tell the user there was nothing to re-probe instead of failing silently.
1005
+ if (failedOnly) setActionNotice(state, 'No failed rows to re-probe - everything looks healthy')
1006
+ return
1007
+ }
984
1008
 
985
1009
  state.probeRunning = true
986
1010
  state.probeTotal = probeable.length
987
1011
  state.probeCompleted = 0
988
1012
  state.probeHiddenCount = 0
1013
+ // 📖 Run token (issue #168): lets a stale reset timer from an earlier probe
1014
+ // 📖 detect that a NEWER probe owns the counters and leave them alone.
1015
+ const runToken = ++probeRunSeq
989
1016
 
990
1017
  const autoHide = state.config.settings?.autoHideBrokenModels !== false
991
1018
  const HTTP_CODES_404 = new Set([404, '404'])
992
1019
  const HTTP_CODES_GONE = new Set([410, '410'])
993
1020
 
994
- const tasks = probeable.map(r => async () => {
995
- const apiKey = getApiKey(state.config, r.providerKey) ?? null
996
- const providerUrl = sources[r.providerKey]?.url ?? null
997
- if (!apiKey || !providerUrl) {
998
- state.probeCompleted++
999
- return { model: r, ok: false, reason: 'no_key' }
1000
- }
1021
+ try {
1022
+ const tasks = probeable.map(r => async () => {
1023
+ const apiKey = getApiKey(state.config, r.providerKey) ?? null
1024
+ const providerUrl = sources[r.providerKey]?.url ?? null
1025
+ if (!apiKey || !providerUrl) {
1026
+ state.probeCompleted++
1027
+ return { model: r, ok: false, reason: 'no_key' }
1028
+ }
1029
+
1030
+ // 📖 Single-increment rule (issue #168 kick-out fix): the old shape
1031
+ // 📖 incremented probeCompleted after the ping AND again in the catch
1032
+ // 📖 whenever a status update threw in between, so completed could
1033
+ // 📖 exceed total. The footer progress bar then rendered
1034
+ // 📖 '░'.repeat(negative) → RangeError → render loop crashed → the TUI
1035
+ // 📖 process exited ("kicked me out"). Now the ping is settled in its
1036
+ // 📖 own try/catch and the counter increments EXACTLY once per task,
1037
+ // 📖 after all fallible work that could double-fire it.
1038
+ let code = null
1039
+ let probeError = null
1040
+ try {
1041
+ const res = await ping(apiKey, r.modelId, r.providerKey, providerUrl)
1042
+ code = res.code
1043
+ } catch (err) {
1044
+ probeError = err?.message
1045
+ }
1001
1046
 
1002
- try {
1003
- const { code } = await ping(apiKey, r.modelId, r.providerKey, providerUrl)
1004
1047
  state.probeCompleted++
1005
1048
 
1049
+ if (probeError) {
1050
+ return { model: r, ok: false, reason: 'error', error: probeError }
1051
+ }
1052
+
1006
1053
  if (HTTP_CODES_404.has(code) || HTTP_CODES_GONE.has(code)) {
1007
- // 📖 Model is broken (404/410) — mark it
1054
+ // 📖 Model is broken (404/410) - mark it
1008
1055
  r.status = 'down'
1009
1056
  r.httpCode = String(code)
1010
1057
 
1011
1058
  if (autoHide) {
1012
- const modelKey = `${r.providerKey}/${r.modelId}`
1013
- if (!state.config.hiddenModels) state.config.hiddenModels = new Set()
1014
- state.config.hiddenModels.add(modelKey)
1015
- r.hidden = true
1016
- state.probeHiddenCount++
1059
+ try {
1060
+ const modelKey = `${r.providerKey}/${r.modelId}`
1061
+ if (!state.config.hiddenModels) state.config.hiddenModels = new Set()
1062
+ state.config.hiddenModels.add(modelKey)
1063
+ r.hidden = true
1064
+ state.probeHiddenCount++
1065
+ } catch { /* 📖 auto-hide must never kill the probe */ }
1017
1066
  }
1018
1067
 
1019
1068
  return { model: r, ok: false, code, reason: 'broken' }
1020
- } else if (code === '200') {
1021
- // 📖 Model is alive — unhide if it was previously hidden by probe
1022
- const modelKey = `${r.providerKey}/${r.modelId}`
1023
- if (state.config.hiddenModels?.has(modelKey)) {
1024
- state.config.hiddenModels.delete(modelKey)
1025
- r.hidden = false
1026
- }
1069
+ }
1070
+
1071
+ if (code === '200') {
1072
+ // 📖 Model is alive - unhide if it was previously hidden by probe
1073
+ try {
1074
+ const modelKey = `${r.providerKey}/${r.modelId}`
1075
+ if (state.config.hiddenModels?.has(modelKey)) {
1076
+ state.config.hiddenModels.delete(modelKey)
1077
+ r.hidden = false
1078
+ }
1079
+ } catch { /* 📖 unhide must never kill the probe */ }
1027
1080
  r.status = 'up'
1028
1081
  return { model: r, ok: true, code }
1029
- } else {
1030
- // 📖 Other codes (401, 429, etc.) — leave status unchanged, don't hide
1031
- return { model: r, ok: false, code, reason: 'other' }
1032
1082
  }
1033
- } catch (err) {
1034
- state.probeCompleted++
1035
- return { model: r, ok: false, reason: 'error', error: err?.message }
1036
- }
1037
- })
1038
1083
 
1039
- await runWithConcurrency(tasks, 5)
1084
+ // 📖 Other codes (401, 429, etc.) - leave status unchanged, don't hide
1085
+ return { model: r, ok: false, code, reason: 'other' }
1086
+ })
1040
1087
 
1041
- // 📖 Persist hidden models set to config
1042
- if (autoHide && state.probeHiddenCount > 0) {
1043
- saveConfig(state.config)
1044
- }
1088
+ await runWithConcurrency(tasks, 5)
1045
1089
 
1046
- state.probeRunning = false
1047
- // 📖 Keep totals visible for a few seconds so user can see results
1048
- setTimeout(() => {
1049
- if (!state.probeRunning) {
1050
- state.probeTotal = 0
1051
- state.probeCompleted = 0
1052
- // 📖 Don't reset probeHiddenCount immediately — user needs to see how many were hidden
1053
- setTimeout(() => {
1054
- state.probeHiddenCount = 0
1055
- }, 5000)
1090
+ // 📖 Persist hidden models set to config
1091
+ if (autoHide && state.probeHiddenCount > 0) {
1092
+ saveConfig(state.config)
1056
1093
  }
1057
- }, 3000)
1094
+ } catch (err) {
1095
+ // 📖 Never let a probe crash the TUI (issue #168): surface and keep running.
1096
+ setActionNotice(state, `Probe failed: ${err?.message || 'unknown error'}`)
1097
+ } finally {
1098
+ state.probeRunning = false
1099
+ // 📖 Keep totals visible for a few seconds so user can see results.
1100
+ // 📖 The run token makes a STALE timer from a previous probe a no-op if a
1101
+ // 📖 newer probe has started meanwhile (it owns the counters now).
1102
+ setTimeout(() => {
1103
+ if (runToken === probeRunSeq && !state.probeRunning) {
1104
+ state.probeTotal = 0
1105
+ state.probeCompleted = 0
1106
+ // 📖 Don't reset probeHiddenCount immediately - user needs to see how many were hidden
1107
+ setTimeout(() => {
1108
+ if (runToken === probeRunSeq) state.probeHiddenCount = 0
1109
+ }, 5000)
1110
+ }
1111
+ }, 3000)
1112
+ }
1058
1113
  }
1059
1114
 
1060
1115
  // 📖 runGlobalBenchmark: Benchmark all visible models with up to 5 concurrent requests.
@@ -1293,6 +1348,22 @@ export function createKeyHandler(ctx) {
1293
1348
  state.commandPaletteResults = []
1294
1349
  }
1295
1350
 
1351
+ // 📖 runPaletteCommandSafe: Execute a palette entry without ever letting a
1352
+ // 📖 rejection escape. ROOT CAUSE of the issue #168 "kicked out" bug: the
1353
+ // 📖 palette used to call executeCommandPaletteEntry() fire-and-forget, so a
1354
+ // 📖 rejected command promise (e.g. the async 404 probe hitting a save or
1355
+ // 📖 network edge case) became an unhandled promise rejection and Node's
1356
+ // 📖 default behavior (since v15) terminated the whole TUI process, dumping
1357
+ // 📖 the user out of the alternate screen. Failures now surface as a footer
1358
+ // 📖 notice and the TUI stays alive.
1359
+ async function runPaletteCommandSafe(entry) {
1360
+ try {
1361
+ await executeCommandPaletteEntry(entry)
1362
+ } catch (err) {
1363
+ setActionNotice(state, `Command failed: ${err?.message || err}`)
1364
+ }
1365
+ }
1366
+
1296
1367
  function executeCommandPaletteEntry(entry) {
1297
1368
  if (!entry?.id) return
1298
1369
 
@@ -1413,6 +1484,7 @@ export function createKeyHandler(ctx) {
1413
1484
  case 'action-toggle-favorite': return toggleFavoriteOnSelectedRow()
1414
1485
  case 'action-toggle-favorite-mode': return toggleFavoritesDisplayMode()
1415
1486
  case 'action-reset-view': return resetViewSettings()
1487
+ case 'action-probe-failed': return runBrokenModelProbe(state, { failedOnly: true })
1416
1488
  case 'action-probe-404': return runBrokenModelProbe(state)
1417
1489
  case 'action-toggle-auto-hide-broken': return toggleAutoHideBrokenModels()
1418
1490
  default:
@@ -1429,7 +1501,10 @@ export function createKeyHandler(ctx) {
1429
1501
  if ((key.ctrl && key.name === 'c') || str === '\x03') { exit(0); return }
1430
1502
 
1431
1503
  // 📖 Ctrl+P toggles the command palette from the main table only.
1432
- if (key.ctrl && key.name === 'p') {
1504
+ // 📖 The !key.shift guard matters (issue #168): many terminals report
1505
+ // 📖 Ctrl+Shift+P as ctrl+p with shift set (or as plain ctrl+p), and without
1506
+ // 📖 the guard the palette swallowed the 404 probe shortcut completely.
1507
+ if (key.ctrl && key.name === 'p' && !key.shift) {
1433
1508
  if (state.commandPaletteOpen) {
1434
1509
  closeCommandPalette()
1435
1510
  return
@@ -1448,14 +1523,10 @@ export function createKeyHandler(ctx) {
1448
1523
  return
1449
1524
  }
1450
1525
 
1451
- // 📖 Ctrl+Shift+P: Probe all configured models for 404/broken endpoints.
1452
- // 📖 Sends a real chat-completion request to every model that has an API key.
1453
- // 📖 Models returning 404/410 are auto-hidden (when setting is enabled).
1454
- // 📖 Results flash live in the table — models flip from down→up in real time.
1455
- if (key.ctrl && key.shift && key.name === 'p') {
1456
- await runBrokenModelProbe(state)
1457
- return
1458
- }
1526
+ // 📖 Note: the 404 probe keybindings (Shift+P = failed rows only, Ctrl+Shift+P
1527
+ // 📖 = all models) live in the main-table section below, AFTER the overlay
1528
+ // 📖 blocks, so overlays like the command palette swallow them instead of
1529
+ // 📖 triggering a probe behind a frozen screen (issue #168).
1459
1530
 
1460
1531
  // 📖 Command palette captures the keyboard while active.
1461
1532
  if (state.commandPaletteOpen) {
@@ -1493,7 +1564,9 @@ export function createKeyHandler(ctx) {
1493
1564
  refreshCommandPaletteResults()
1494
1565
  } else if (selected?.type === 'command') {
1495
1566
  closeCommandPalette()
1496
- executeCommandPaletteEntry(selected)
1567
+ // 📖 Awaited via safe wrapper: a rejected command must never become an
1568
+ // 📖 unhandled promise rejection (issue #168 "kicked out" bug).
1569
+ runPaletteCommandSafe(selected)
1497
1570
  }
1498
1571
  return
1499
1572
  }
@@ -1531,7 +1604,9 @@ export function createKeyHandler(ctx) {
1531
1604
  refreshCommandPaletteResults()
1532
1605
  } else {
1533
1606
  closeCommandPalette()
1534
- executeCommandPaletteEntry(selected)
1607
+ // 📖 Awaited via safe wrapper: a rejected command must never become an
1608
+ // 📖 unhandled promise rejection (issue #168 "kicked out" bug).
1609
+ runPaletteCommandSafe(selected)
1535
1610
  }
1536
1611
  return
1537
1612
  }
@@ -2829,6 +2904,37 @@ export function createKeyHandler(ctx) {
2829
2904
  return // 📖 Swallow all other keys while settings is open
2830
2905
  }
2831
2906
 
2907
+ // 📖 Space: toggle a 2-line detail card under the selected row (issue #168).
2908
+ // 📖 When columns are tight, provider and model specifics get truncated; the
2909
+ // 📖 detail card gives the selected row room to express them. Pressing Space
2910
+ // 📖 again (or moving the cursor) collapses it. Cursor moves clear the key in
2911
+ // 📖 the up/down handlers below, so the card always follows the highlight.
2912
+ if (key.name === 'space' && !key.ctrl && !key.meta && !key.shift) {
2913
+ const selected = state.visibleSorted[state.cursor]
2914
+ if (!selected) return
2915
+ const rowKey = `${selected.providerKey}/${selected.modelId}`
2916
+ state.expandedRowKey = state.expandedRowKey === rowKey ? null : rowKey
2917
+ return
2918
+ }
2919
+
2920
+ // 📖 Shift+P: Re-probe ONLY the rows currently showing errors (auth fail,
2921
+ // 📖 429, 404, timeout). Issue #168: users with 1-2 flaky rows want a cheap
2922
+ // 📖 rescan without probing (and burning quota on) the whole list.
2923
+ // 📖 Shift+P is the requested replacement for Ctrl+Shift+P, which most
2924
+ // 📖 terminals cannot send as a distinct key.
2925
+ if (key.name === 'p' && key.shift && !key.ctrl && !key.meta) {
2926
+ await runBrokenModelProbe(state, { failedOnly: true })
2927
+ return
2928
+ }
2929
+
2930
+ // 📖 Ctrl+Shift+P: Probe ALL configured models for 404/broken endpoints
2931
+ // 📖 (same as the "Probe all models" palette entry). Kept for terminals
2932
+ // 📖 that can report Ctrl+Shift combos; Shift+P covers everyone else.
2933
+ if (key.ctrl && key.shift && key.name === 'p') {
2934
+ await runBrokenModelProbe(state)
2935
+ return
2936
+ }
2937
+
2832
2938
  // 📖 P key: open settings screen
2833
2939
  if (key.name === 'p' && !key.shift && !key.ctrl && !key.meta) {
2834
2940
  openSettingsOverlay()
@@ -3066,6 +3172,9 @@ export function createKeyHandler(ctx) {
3066
3172
  const count = state.visibleSorted.length
3067
3173
  if (count === 0) return
3068
3174
  state.cursor = state.cursor > 0 ? state.cursor - 1 : count - 1
3175
+ // 📖 Moving the cursor collapses the Space detail card (issue #168) so it
3176
+ // 📖 always tracks the highlighted row instead of lingering on an old one.
3177
+ state.expandedRowKey = null
3069
3178
  adjustScrollOffset(state)
3070
3179
  return
3071
3180
  }
@@ -3075,6 +3184,8 @@ export function createKeyHandler(ctx) {
3075
3184
  const count = state.visibleSorted.length
3076
3185
  if (count === 0) return
3077
3186
  state.cursor = state.cursor < count - 1 ? state.cursor + 1 : 0
3187
+ // 📖 Moving the cursor collapses the Space detail card (see up handler).
3188
+ state.expandedRowKey = null
3078
3189
  adjustScrollOffset(state)
3079
3190
  return
3080
3191
  }