@asterxsk/kiln 0.1.0 → 0.2.0
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/LICENSE +21 -21
- package/README.md +170 -170
- package/agent/AGENTS.md +67 -67
- package/agent/README.md +5 -5
- package/agent/extensions/AGENTS.md +68 -68
- package/agent/extensions/ask-user/index.ts +418 -418
- package/agent/extensions/ask-user/package-lock.json +769 -769
- package/agent/extensions/ask-user/package.json +19 -19
- package/agent/extensions/ask-user/prompt.ts +45 -45
- package/agent/extensions/ask-user/tsconfig.json +7 -7
- package/agent/extensions/background-terminals/docs/implementation-guide.md +942 -942
- package/agent/extensions/background-terminals/index.ts +627 -627
- package/agent/extensions/background-terminals/manager.test.ts +735 -735
- package/agent/extensions/background-terminals/output.test.ts +109 -109
- package/agent/extensions/background-terminals/package-lock.json +769 -769
- package/agent/extensions/background-terminals/package.json +17 -17
- package/agent/extensions/background-terminals/prompt.test.ts +125 -125
- package/agent/extensions/background-terminals/ps.test.ts +82 -82
- package/agent/extensions/background-terminals/result-delivery.test.ts +44 -44
- package/agent/extensions/background-terminals/src/domain.ts +87 -87
- package/agent/extensions/background-terminals/src/manager.ts +907 -907
- package/agent/extensions/background-terminals/src/output.ts +84 -84
- package/agent/extensions/background-terminals/src/prompt.ts +142 -142
- package/agent/extensions/background-terminals/src/result-delivery.ts +27 -27
- package/agent/extensions/background-terminals/src/runtime.ts +36 -36
- package/agent/extensions/background-terminals/src/ui/output-view.ts +79 -79
- package/agent/extensions/background-terminals/src/ui/ps.ts +621 -621
- package/agent/extensions/background-terminals/tsconfig.json +7 -7
- package/agent/extensions/file-search/index.spec.ts +443 -443
- package/agent/extensions/file-search/index.ts +459 -459
- package/agent/extensions/file-search/package-lock.json +2253 -2253
- package/agent/extensions/file-search/package.json +23 -23
- package/agent/extensions/file-search/src/args.ts +122 -122
- package/agent/extensions/file-search/src/binaries.ts +422 -422
- package/agent/extensions/file-search/src/output.ts +126 -126
- package/agent/extensions/file-search/src/process.ts +146 -146
- package/agent/extensions/file-search/src/prompt.ts +52 -52
- package/agent/extensions/file-search/tsconfig.json +7 -7
- package/agent/extensions/modelconf/PLAN.md +915 -915
- package/agent/extensions/modelconf/index.ts +296 -296
- package/agent/extensions/modelconf/src/ui/ModelConfView.ts +1101 -1101
- package/agent/extensions/pi-web-access/CHANGELOG.md +690 -690
- package/agent/extensions/pi-web-access/LICENSE +21 -21
- package/agent/extensions/pi-web-access/README.md +470 -470
- package/agent/extensions/pi-web-access/SECURITY.md +5 -5
- package/agent/extensions/pi-web-access/activity.ts +101 -101
- package/agent/extensions/pi-web-access/auth-fetch.ts +148 -148
- package/agent/extensions/pi-web-access/brightdata-unlocker.ts +272 -272
- package/agent/extensions/pi-web-access/chrome-cookies.ts +669 -669
- package/agent/extensions/pi-web-access/content-find.ts +139 -139
- package/agent/extensions/pi-web-access/credential-source.ts +191 -191
- package/agent/extensions/pi-web-access/data-uri-sanitize.ts +406 -406
- package/agent/extensions/pi-web-access/datalab-pdf-extract.ts +568 -568
- package/agent/extensions/pi-web-access/declared-web-links.ts +173 -173
- package/agent/extensions/pi-web-access/evidence/CONTRACT-EVIDENCE.md +496 -496
- package/agent/extensions/pi-web-access/evidence/contract-probe.mjs +140 -140
- package/agent/extensions/pi-web-access/exa.ts +526 -526
- package/agent/extensions/pi-web-access/extract.ts +1196 -1196
- package/agent/extensions/pi-web-access/feature-config.ts +29 -29
- package/agent/extensions/pi-web-access/fetch-params.ts +111 -111
- package/agent/extensions/pi-web-access/gemini-adc.ts +298 -298
- package/agent/extensions/pi-web-access/gemini-api.ts +353 -353
- package/agent/extensions/pi-web-access/gemini-pdf-extract.ts +108 -108
- package/agent/extensions/pi-web-access/gemini-url-context.ts +128 -128
- package/agent/extensions/pi-web-access/gemini-web-config.ts +101 -101
- package/agent/extensions/pi-web-access/gemini-web.ts +487 -487
- package/agent/extensions/pi-web-access/github-api.ts +197 -197
- package/agent/extensions/pi-web-access/github-extract.ts +746 -746
- package/agent/extensions/pi-web-access/github-issue-pr.ts +700 -700
- package/agent/extensions/pi-web-access/index.ts +1737 -1737
- package/agent/extensions/pi-web-access/package-lock.json +5808 -5808
- package/agent/extensions/pi-web-access/package.json +64 -64
- package/agent/extensions/pi-web-access/page-query.ts +96 -96
- package/agent/extensions/pi-web-access/pdf-extract.ts +409 -409
- package/agent/extensions/pi-web-access/promise-try.d.ts +7 -7
- package/agent/extensions/pi-web-access/query-rewrite.ts +51 -51
- package/agent/extensions/pi-web-access/render-search-error.ts +170 -170
- package/agent/extensions/pi-web-access/rsc-extract.ts +338 -338
- package/agent/extensions/pi-web-access/source-check.ts +282 -282
- package/agent/extensions/pi-web-access/ssrf-protection.ts +526 -526
- package/agent/extensions/pi-web-access/storage.ts +521 -521
- package/agent/extensions/pi-web-access/summary-model-scope.ts +125 -125
- package/agent/extensions/pi-web-access/test/auth-fetch.test.mjs +208 -208
- package/agent/extensions/pi-web-access/test/brightdata-unlocker.test.mjs +840 -840
- package/agent/extensions/pi-web-access/test/chrome-cookie-extraction.test.mjs +441 -441
- package/agent/extensions/pi-web-access/test/config-path.test.mjs +283 -283
- package/agent/extensions/pi-web-access/test/content-find.test.mjs +25 -25
- package/agent/extensions/pi-web-access/test/credential-source.test.mjs +118 -118
- package/agent/extensions/pi-web-access/test/data-uri-sanitize.test.mjs +210 -210
- package/agent/extensions/pi-web-access/test/datalab-pdf-extract.test.mjs +552 -552
- package/agent/extensions/pi-web-access/test/declared-web-links.test.mjs +212 -212
- package/agent/extensions/pi-web-access/test/fetch-answer-storage.test.mjs +40 -40
- package/agent/extensions/pi-web-access/test/fetch-cache-storage.test.mjs +334 -334
- package/agent/extensions/pi-web-access/test/fetch-content-domain-policy.test.mjs +95 -95
- package/agent/extensions/pi-web-access/test/fetch-modes.test.mjs +53 -53
- package/agent/extensions/pi-web-access/test/fetch-not-found-guidance.test.mjs +92 -92
- package/agent/extensions/pi-web-access/test/fetch-params.test.mjs +86 -86
- package/agent/extensions/pi-web-access/test/fetch-render-call.test.mjs +34 -34
- package/agent/extensions/pi-web-access/test/fetch-routing.test.mjs +173 -173
- package/agent/extensions/pi-web-access/test/gemini-adc-auth.test.mjs +257 -257
- package/agent/extensions/pi-web-access/test/gemini-api-transport.test.mjs +170 -170
- package/agent/extensions/pi-web-access/test/gemini-pdf-extract.test.mjs +133 -133
- package/agent/extensions/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +178 -178
- package/agent/extensions/pi-web-access/test/gemini-web-header-overflow.test.mjs +148 -148
- package/agent/extensions/pi-web-access/test/get-search-content.test.mjs +223 -223
- package/agent/extensions/pi-web-access/test/github-extract.test.mjs +378 -378
- package/agent/extensions/pi-web-access/test/github-issue-pr.test.mjs +565 -565
- package/agent/extensions/pi-web-access/test/inline-content-config.test.mjs +99 -99
- package/agent/extensions/pi-web-access/test/lazy-extract-load.test.mjs +118 -118
- package/agent/extensions/pi-web-access/test/local-video-oversize.test.mjs +52 -52
- package/agent/extensions/pi-web-access/test/package-typebox-dependency.test.mjs +50 -50
- package/agent/extensions/pi-web-access/test/page-query.test.mjs +51 -51
- package/agent/extensions/pi-web-access/test/pdf-config.test.mjs +140 -140
- package/agent/extensions/pi-web-access/test/pdf-extract.test.mjs +500 -500
- package/agent/extensions/pi-web-access/test/proxy-transport.test.mjs +286 -286
- package/agent/extensions/pi-web-access/test/query-rewrite.test.mjs +52 -52
- package/agent/extensions/pi-web-access/test/rsc-fallback.test.mjs +102 -102
- package/agent/extensions/pi-web-access/test/search-error-render.test.mjs +152 -152
- package/agent/extensions/pi-web-access/test/search-providers.test.mjs +274 -274
- package/agent/extensions/pi-web-access/test/source-check.test.mjs +179 -179
- package/agent/extensions/pi-web-access/test/ssrf-allow-ranges-config.test.mjs +205 -205
- package/agent/extensions/pi-web-access/test/ssrf-protection.test.mjs +456 -456
- package/agent/extensions/pi-web-access/test/tool-registration-config.test.mjs +182 -182
- package/agent/extensions/pi-web-access/test/youtube-extract-errors.test.mjs +64 -64
- package/agent/extensions/pi-web-access/tsconfig.json +11 -11
- package/agent/extensions/pi-web-access/utils.ts +451 -451
- package/agent/extensions/pi-web-access/video-extract.ts +392 -392
- package/agent/extensions/pi-web-access/youtube-extract.ts +328 -328
- package/agent/extensions/shared/activity-status.ts +31 -31
- package/agent/extensions/shared/child-session.test.ts +270 -270
- package/agent/extensions/shared/child-session.ts +148 -148
- package/agent/extensions/shared/context-utilization.test.ts +48 -48
- package/agent/extensions/shared/context-utilization.ts +47 -47
- package/agent/extensions/shared/dashboard-state.ts +99 -99
- package/agent/extensions/shared/tool-call-timeout.test.ts +117 -117
- package/agent/extensions/shared/tool-call-timeout.ts +104 -104
- package/agent/extensions/subagents/by-the-way.test.ts +29 -29
- package/agent/extensions/subagents/claude.test.ts +119 -119
- package/agent/extensions/subagents/codex.test.ts +102 -102
- package/agent/extensions/subagents/context-usage.test.ts +107 -107
- package/agent/extensions/subagents/docs/design-plan.md +568 -568
- package/agent/extensions/subagents/docs/effect-v4-extension-guide.md +354 -354
- package/agent/extensions/subagents/docs/effect-v4-notes.md +571 -571
- package/agent/extensions/subagents/index.ts +779 -779
- package/agent/extensions/subagents/manager.test.ts +276 -276
- package/agent/extensions/subagents/package-lock.json +2244 -2244
- package/agent/extensions/subagents/package.json +19 -19
- package/agent/extensions/subagents/result-delivery.test.ts +27 -27
- package/agent/extensions/subagents/src/backend.ts +73 -73
- package/agent/extensions/subagents/src/backends/claude.ts +701 -701
- package/agent/extensions/subagents/src/backends/codex.ts +1060 -1060
- package/agent/extensions/subagents/src/backends/pi.ts +575 -575
- package/agent/extensions/subagents/src/backends/stub.ts +300 -300
- package/agent/extensions/subagents/src/by-the-way.ts +21 -21
- package/agent/extensions/subagents/src/domain.ts +253 -253
- package/agent/extensions/subagents/src/format.ts +74 -74
- package/agent/extensions/subagents/src/manager.ts +736 -736
- package/agent/extensions/subagents/src/prompt.ts +92 -92
- package/agent/extensions/subagents/src/result-delivery.ts +20 -20
- package/agent/extensions/subagents/src/runtime.ts +53 -53
- package/agent/extensions/subagents/src/ui/takeover.ts +583 -583
- package/agent/extensions/subagents/src/ui/transcript.ts +201 -201
- package/agent/extensions/subagents/takeover.test.ts +29 -29
- package/agent/extensions/subagents/tsconfig.json +7 -7
- package/agent/extensions/todo/AGENTS.md +38 -38
- package/agent/extensions/todo/LICENSE +21 -21
- package/agent/extensions/todo/config.ts +55 -55
- package/agent/extensions/todo/index.ts +151 -151
- package/agent/extensions/todo/locales/de.json +17 -17
- package/agent/extensions/todo/locales/en.json +15 -15
- package/agent/extensions/todo/locales/es.json +17 -17
- package/agent/extensions/todo/locales/fr.json +17 -17
- package/agent/extensions/todo/locales/pt-BR.json +17 -17
- package/agent/extensions/todo/locales/pt.json +17 -17
- package/agent/extensions/todo/locales/ru.json +17 -17
- package/agent/extensions/todo/locales/uk.json +17 -17
- package/agent/extensions/todo/locales/zh.json +17 -17
- package/agent/extensions/todo/package-lock.json +3358 -3358
- package/agent/extensions/todo/package.json +67 -67
- package/agent/extensions/todo/state/i18n-bridge.ts +64 -64
- package/agent/extensions/todo/state/invariants.ts +20 -20
- package/agent/extensions/todo/state/replay.ts +38 -38
- package/agent/extensions/todo/state/selectors.ts +107 -107
- package/agent/extensions/todo/state/state-reducer.ts +326 -326
- package/agent/extensions/todo/state/state.ts +18 -18
- package/agent/extensions/todo/state/store.ts +82 -82
- package/agent/extensions/todo/state/task-graph.ts +57 -57
- package/agent/extensions/todo/todo-overlay.ts +200 -200
- package/agent/extensions/todo/todo.ts +155 -155
- package/agent/extensions/todo/tool/response-envelope.ts +109 -109
- package/agent/extensions/todo/tool/types.ts +206 -206
- package/agent/extensions/todo/view/format.ts +177 -177
- package/agent/install.ps1 +637 -527
- package/agent/install.sh +620 -511
- package/agent/keybindings.json +7 -7
- package/bin/kiln.js +124 -11
- package/package.json +8 -2
- package/agent/extensions/taste/index.ts +0 -443
- package/agent/extensions/taste/install.ps1 +0 -23
- package/agent/extensions/taste/install.sh +0 -21
- /package/agent/extensions/{status line → statusline}/index.ts +0 -0
- /package/agent/extensions/{status line → statusline}/install.ps1 +0 -0
- /package/agent/extensions/{status line → statusline}/install.sh +0 -0
|
@@ -1,1101 +1,1101 @@
|
|
|
1
|
-
import { applyBulk, toggleInEnabled } from "../persistence.js";
|
|
2
|
-
import type { ModelId } from "../persistence.js";
|
|
3
|
-
import { globToRegExp } from "../glob.js";
|
|
4
|
-
import { fuzzyFilter } from "../fuzzy.js";
|
|
5
|
-
// Minimal Component type to avoid requiring @earendil-works/pi-tui at build time.
|
|
6
|
-
// We mirror the pi-tui Component contract used by ctx.ui.custom.
|
|
7
|
-
export interface Component {
|
|
8
|
-
render(width: number): string[];
|
|
9
|
-
handleInput?(data: string): void;
|
|
10
|
-
invalidate(): void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Focusable contract per docs/tui.md — propagate focused to child Input
|
|
14
|
-
export interface Focusable {
|
|
15
|
-
focused: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// ---- local helpers for width/truncation (fallback when pi-tui not available) ----
|
|
19
|
-
|
|
20
|
-
function stripAnsi(s: string): string {
|
|
21
|
-
// strip CSI ...m, OSC 8 hyperlink, and APC cursor marker
|
|
22
|
-
return s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\]8;;.*?\x1b\\/g, "").replace(/\x1b_pi:c\x07/g, "");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function visibleWidthFallback(s: string): number {
|
|
26
|
-
const stripped = stripAnsi(s);
|
|
27
|
-
let w = 0;
|
|
28
|
-
for (const ch of stripped) {
|
|
29
|
-
const cp = ch.codePointAt(0) ?? 0;
|
|
30
|
-
// crude wide char detection: CJK + emoji ranges
|
|
31
|
-
if (cp >= 0x1100 && (
|
|
32
|
-
cp <= 0x115F || cp === 0x2329 || cp === 0x232A ||
|
|
33
|
-
(cp >= 0x2E80 && cp <= 0xA4CF && cp !== 0x303F) ||
|
|
34
|
-
(cp >= 0xAC00 && cp <= 0xD7A3) ||
|
|
35
|
-
(cp >= 0xF900 && cp <= 0xFAFF) ||
|
|
36
|
-
(cp >= 0xFE10 && cp <= 0xFE19) ||
|
|
37
|
-
(cp >= 0xFE30 && cp <= 0xFE6F) ||
|
|
38
|
-
(cp >= 0xFF00 && cp <= 0xFF60) ||
|
|
39
|
-
(cp >= 0xFFE0 && cp <= 0xFFE6)
|
|
40
|
-
)) {
|
|
41
|
-
w += 2;
|
|
42
|
-
} else {
|
|
43
|
-
w += 1;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return w;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
|
50
|
-
if (str[pos] !== "\x1b") return null;
|
|
51
|
-
const next = str[pos + 1];
|
|
52
|
-
if (next === "[") {
|
|
53
|
-
let i = pos + 2;
|
|
54
|
-
while (i < str.length) {
|
|
55
|
-
const c = str[i];
|
|
56
|
-
if ((c >= "0" && c <= "9") || c === ";" || c === ":" || c === "?" ) { i++; continue; }
|
|
57
|
-
// final char
|
|
58
|
-
i++;
|
|
59
|
-
return { code: str.slice(pos, i), length: i - pos };
|
|
60
|
-
}
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
if (next === "]") {
|
|
64
|
-
const end = str.indexOf("\x1b\\", pos);
|
|
65
|
-
if (end !== -1) return { code: str.slice(pos, end + 2), length: end + 2 - pos };
|
|
66
|
-
const bel = str.indexOf("\x07", pos);
|
|
67
|
-
if (bel !== -1) return { code: str.slice(pos, bel + 1), length: bel + 1 - pos };
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
if (str.startsWith("\x1b_pi:c\x07", pos)) return { code: "\x1b_pi:c\x07", length: 7 };
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function truncateFallback(text: string, maxWidth: number, ellipsis = "..."): string {
|
|
75
|
-
if (maxWidth <= 0) return "";
|
|
76
|
-
const vw = visibleWidthFallback(text);
|
|
77
|
-
if (vw <= maxWidth) return text;
|
|
78
|
-
const ellW = visibleWidthFallback(ellipsis);
|
|
79
|
-
const target = maxWidth - ellW;
|
|
80
|
-
if (target <= 0) {
|
|
81
|
-
// truncate ellipsis itself if needed
|
|
82
|
-
let out = "";
|
|
83
|
-
let w = 0;
|
|
84
|
-
let i = 0;
|
|
85
|
-
while (i < text.length) {
|
|
86
|
-
const ansi = extractAnsiCode(text, i);
|
|
87
|
-
if (ansi) { out += ansi.code; i += ansi.length; continue; }
|
|
88
|
-
// grapheme: take one codepoint
|
|
89
|
-
const ch = text[i];
|
|
90
|
-
// handle surrogate pair
|
|
91
|
-
let grapheme = ch;
|
|
92
|
-
if (ch.charCodeAt(0) >= 0xD800 && ch.charCodeAt(0) <= 0xDBFF && i + 1 < text.length) {
|
|
93
|
-
grapheme = text.slice(i, i + 2);
|
|
94
|
-
}
|
|
95
|
-
const gw = visibleWidthFallback(grapheme);
|
|
96
|
-
if (w + gw > maxWidth) break;
|
|
97
|
-
out += grapheme;
|
|
98
|
-
w += gw;
|
|
99
|
-
i += grapheme.length;
|
|
100
|
-
}
|
|
101
|
-
return out;
|
|
102
|
-
}
|
|
103
|
-
let out = "";
|
|
104
|
-
let w = 0;
|
|
105
|
-
let i = 0;
|
|
106
|
-
let pendingAnsi = "";
|
|
107
|
-
while (i < text.length) {
|
|
108
|
-
const ansi = extractAnsiCode(text, i);
|
|
109
|
-
if (ansi) { pendingAnsi += ansi.code; out += ansi.code; i += ansi.length; continue; }
|
|
110
|
-
let grapheme = text[i];
|
|
111
|
-
if (grapheme.charCodeAt(0) >= 0xD800 && grapheme.charCodeAt(0) <= 0xDBFF && i + 1 < text.length) {
|
|
112
|
-
grapheme = text.slice(i, i + 2);
|
|
113
|
-
}
|
|
114
|
-
const gw = visibleWidthFallback(grapheme);
|
|
115
|
-
if (w + gw > target) break;
|
|
116
|
-
out += grapheme;
|
|
117
|
-
w += gw;
|
|
118
|
-
i += grapheme.length;
|
|
119
|
-
}
|
|
120
|
-
out += ellipsis;
|
|
121
|
-
// Append pending ansi resets? naive: ensure reset at end not needed
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Try to use pi-tui's truncate if available at runtime (optional). For compile-time we use fallback.
|
|
126
|
-
let truncateToWidthFn: (text: string, maxWidth: number, ellipsis?: string, pad?: boolean) => string = truncateFallback;
|
|
127
|
-
try {
|
|
128
|
-
// @ts-ignore - optional dynamic import may fail at build if module not installed; fallback is fine
|
|
129
|
-
// We keep fallback as default; runtime override attempted via dynamic import is not critical
|
|
130
|
-
} catch {}
|
|
131
|
-
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
// Simple Input fallback — mimics @earendil-works/pi-tui Input for search mode
|
|
134
|
-
// Handles printable chars, backspace (\x7f, \x08), delete, left/right arrows.
|
|
135
|
-
// Propagates focused flag per Focusable contract. Fallback when pi-tui Input not resolvable.
|
|
136
|
-
|
|
137
|
-
class SimpleSearchInput implements Component, Focusable {
|
|
138
|
-
value = "";
|
|
139
|
-
focused = false;
|
|
140
|
-
private cursor = 0;
|
|
141
|
-
// Keep for compatibility with pi-tui Input shape
|
|
142
|
-
getValue(): string { return this.value; }
|
|
143
|
-
setValue(v: string): void { this.value = v; this.cursor = v.length; }
|
|
144
|
-
|
|
145
|
-
handleInput(data: string): void {
|
|
146
|
-
if (!data) return;
|
|
147
|
-
// Handle bracketed paste markers — strip and insert content
|
|
148
|
-
if (data.includes("\x1b[200~")) {
|
|
149
|
-
const start = data.indexOf("\x1b[200~");
|
|
150
|
-
const end = data.indexOf("\x1b[201~");
|
|
151
|
-
if (end !== -1) {
|
|
152
|
-
const paste = data.substring(start + 6, end);
|
|
153
|
-
this.insertAtCursor(paste);
|
|
154
|
-
const remaining = data.slice(end + 6);
|
|
155
|
-
if (remaining) this.handleInput(remaining);
|
|
156
|
-
return;
|
|
157
|
-
} else {
|
|
158
|
-
// incomplete paste marker — strip marker and insert rest
|
|
159
|
-
data = data.replace("\x1b[200~", "");
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (data.includes("\x1b[201~")) {
|
|
163
|
-
data = data.replace("\x1b[201~", "");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Special keys — handle one at a time if data contains escapes
|
|
167
|
-
// Backspace: \x7f or \x08 or \b
|
|
168
|
-
if (data === "\x7f" || data === "\x08" || data === "\b") {
|
|
169
|
-
this.handleBackspace();
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
// Delete (forward): \x1b[3~
|
|
173
|
-
if (data === "\x1b[3~") {
|
|
174
|
-
this.handleDelete();
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
// Arrows for cursor movement within input
|
|
178
|
-
if (data === "\x1b[D" || data === "\x1bOD") { // left
|
|
179
|
-
if (this.cursor > 0) this.cursor--;
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (data === "\x1b[C" || data === "\x1bOC") { // right
|
|
183
|
-
if (this.cursor < this.value.length) this.cursor++;
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (data === "\x1b[H" || data === "\x1bOH" || data === "\x1b[1~") { // home
|
|
187
|
-
this.cursor = 0;
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (data === "\x1b[F" || data === "\x1bOF" || data === "\x1b[4~") { // end
|
|
191
|
-
this.cursor = this.value.length;
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
// Ctrl-U (kill line)
|
|
195
|
-
if (data === "\x15") {
|
|
196
|
-
this.value = "";
|
|
197
|
-
this.cursor = 0;
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
// Ctrl-W (delete word backwards)
|
|
201
|
-
if (data === "\x17") {
|
|
202
|
-
this.deleteWordBackwards();
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
// If data looks like an escape sequence (arrow, etc.) not handled above and length >1, ignore
|
|
206
|
-
if (data.startsWith("\x1b") && data.length > 1) {
|
|
207
|
-
// Unknown escape — ignore (avoid inserting ESC into query)
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
// Ctrl-C / Ctrl-D etc — ignore
|
|
211
|
-
if (data.length === 1 && data.charCodeAt(0) < 0x20) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
// Printable insertion — insert at cursor (support multi-char paste without bracket markers)
|
|
215
|
-
// Filter out non-printable control chars
|
|
216
|
-
let insert = "";
|
|
217
|
-
for (const ch of data) {
|
|
218
|
-
const code = ch.charCodeAt(0);
|
|
219
|
-
if (code >= 0x20 && code !== 0x7f) insert += ch;
|
|
220
|
-
}
|
|
221
|
-
if (insert) this.insertAtCursor(insert);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private insertAtCursor(text: string): void {
|
|
225
|
-
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
|
226
|
-
this.cursor += text.length;
|
|
227
|
-
}
|
|
228
|
-
private handleBackspace(): void {
|
|
229
|
-
if (this.cursor > 0) {
|
|
230
|
-
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
|
|
231
|
-
this.cursor--;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
private handleDelete(): void {
|
|
235
|
-
if (this.cursor < this.value.length) {
|
|
236
|
-
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
private deleteWordBackwards(): void {
|
|
240
|
-
if (this.cursor === 0) return;
|
|
241
|
-
let pos = this.cursor;
|
|
242
|
-
while (pos > 0 && this.value[pos - 1] === " ") pos--;
|
|
243
|
-
while (pos > 0 && this.value[pos - 1] !== " ") pos--;
|
|
244
|
-
this.value = this.value.slice(0, pos) + this.value.slice(this.cursor);
|
|
245
|
-
this.cursor = pos;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
render(width: number): string[] {
|
|
249
|
-
// Render with cursor marker when focused. Use \x1b_pi:c\x07 marker for IME if needed? Simple underscore.
|
|
250
|
-
// Show placeholder when empty and not focused? When focused, show cursor.
|
|
251
|
-
const cursorMark = "\x1b_pi:c\x07";
|
|
252
|
-
let display: string;
|
|
253
|
-
if (this.focused) {
|
|
254
|
-
const before = this.value.slice(0, this.cursor);
|
|
255
|
-
const after = this.value.slice(this.cursor);
|
|
256
|
-
// Insert cursor marker + visual block; terminal will show cursor at marker
|
|
257
|
-
display = before + cursorMark + (after.length > 0 ? after : " ");
|
|
258
|
-
// Also truncate to width with visibleWidth handling? Keep simple truncation
|
|
259
|
-
// Use truncateFallback to keep ANSI safe (cursorMark is ANSI-like)
|
|
260
|
-
if (visibleWidthFallback(display) > width) {
|
|
261
|
-
// simple scroll: show window around cursor
|
|
262
|
-
const half = Math.floor(width / 2);
|
|
263
|
-
let start = Math.max(0, this.cursor - half);
|
|
264
|
-
// crude slice
|
|
265
|
-
let slice = this.value.slice(start, start + width);
|
|
266
|
-
const curInSlice = this.cursor - start;
|
|
267
|
-
display = slice.slice(0, curInSlice) + cursorMark + slice.slice(curInSlice);
|
|
268
|
-
}
|
|
269
|
-
} else {
|
|
270
|
-
display = this.value;
|
|
271
|
-
}
|
|
272
|
-
// Apply truncate for width
|
|
273
|
-
const line = truncateFallback(display, width);
|
|
274
|
-
return [line];
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
invalidate(): void {}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
|
|
282
|
-
export interface ModelRow {
|
|
283
|
-
id: string; // "provider/modelId"
|
|
284
|
-
provider: string;
|
|
285
|
-
modelId: string;
|
|
286
|
-
visible: boolean;
|
|
287
|
-
name?: string;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export interface ModelConfViewOpts {
|
|
291
|
-
tui: any;
|
|
292
|
-
theme: any;
|
|
293
|
-
keybindings: any;
|
|
294
|
-
allModels: Array<{ provider: string; id: string; name?: string }>;
|
|
295
|
-
visibleSet: Set<string>;
|
|
296
|
-
enabledRaw: string[] | undefined;
|
|
297
|
-
onDone: (saved: boolean) => void;
|
|
298
|
-
onPersist: (nextEnabled: string[] | undefined) => Promise<void>;
|
|
299
|
-
allIds?: string[];
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
type ContentEntry =
|
|
303
|
-
| { kind: "header"; provider: string }
|
|
304
|
-
| { kind: "row"; row: ModelRow; flatIndex: number };
|
|
305
|
-
|
|
306
|
-
export class ModelConfView implements Component, Focusable {
|
|
307
|
-
private tui: any;
|
|
308
|
-
private theme: any;
|
|
309
|
-
private keybindings: any;
|
|
310
|
-
private onDone: (saved: boolean) => void;
|
|
311
|
-
private _onPersist: (next: string[] | undefined) => Promise<void>;
|
|
312
|
-
|
|
313
|
-
private rows: ModelRow[] = [];
|
|
314
|
-
private sortedProviders: string[] = [];
|
|
315
|
-
private rowsByProvider: Map<string, ModelRow[]> = new Map();
|
|
316
|
-
private cursorIndex: number = 0;
|
|
317
|
-
private collapsed: Set<string> = new Set();
|
|
318
|
-
private originalEnabled: string[] | undefined;
|
|
319
|
-
private draftEnabled: string[] | undefined;
|
|
320
|
-
private allIds: string[] = [];
|
|
321
|
-
private pendingDiscard = false;
|
|
322
|
-
|
|
323
|
-
private activeProviderIndex: number = 0;
|
|
324
|
-
private perProviderCursor: Map<string, number> = new Map();
|
|
325
|
-
|
|
326
|
-
private searchActive = false;
|
|
327
|
-
private searchQuery = "";
|
|
328
|
-
private filteredRows: ModelRow[] | null = null;
|
|
329
|
-
private searchInput: SimpleSearchInput;
|
|
330
|
-
private _focused = false;
|
|
331
|
-
get focused(): boolean { return this._focused; }
|
|
332
|
-
set focused(v: boolean) {
|
|
333
|
-
this._focused = v;
|
|
334
|
-
if (this.searchInput) this.searchInput.focused = v && this.searchActive;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private lastBulkMessage: string | null = null;
|
|
338
|
-
private lastBulkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
339
|
-
|
|
340
|
-
constructor(opts: ModelConfViewOpts) {
|
|
341
|
-
this.tui = opts.tui;
|
|
342
|
-
this.theme = opts.theme;
|
|
343
|
-
this.keybindings = opts.keybindings;
|
|
344
|
-
this.onDone = opts.onDone;
|
|
345
|
-
this._onPersist = opts.onPersist;
|
|
346
|
-
|
|
347
|
-
this.searchInput = new SimpleSearchInput();
|
|
348
|
-
try {
|
|
349
|
-
const req: any = (globalThis as any).require ?? (typeof require !== "undefined" ? require : undefined);
|
|
350
|
-
if (req) {
|
|
351
|
-
let mod: any;
|
|
352
|
-
try { mod = req("@earendil-works/pi-tui"); } catch { try { mod = req("@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui"); } catch {} }
|
|
353
|
-
if (mod?.Input) {
|
|
354
|
-
const real = new mod.Input();
|
|
355
|
-
if (typeof real.value === "string" || typeof real.getValue === "function") {
|
|
356
|
-
const wrapped: any = real;
|
|
357
|
-
if (!("value" in wrapped) || typeof wrapped.value !== "string") {
|
|
358
|
-
Object.defineProperty(wrapped, "value", {
|
|
359
|
-
get() { return typeof wrapped.getValue === "function" ? wrapped.getValue() : wrapped._value ?? ""; },
|
|
360
|
-
set(v: string) { if (typeof wrapped.setValue === "function") wrapped.setValue(v); else wrapped._value = v; },
|
|
361
|
-
configurable: true,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
this.searchInput = wrapped;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
} catch {}
|
|
369
|
-
|
|
370
|
-
const all = opts.allModels ?? [];
|
|
371
|
-
this.rows = all.map((m) => {
|
|
372
|
-
const id = `${m.provider}/${m.id}`;
|
|
373
|
-
return {
|
|
374
|
-
id,
|
|
375
|
-
provider: m.provider,
|
|
376
|
-
modelId: m.id,
|
|
377
|
-
visible: opts.visibleSet.has(id),
|
|
378
|
-
name: m.name ?? m.id,
|
|
379
|
-
} as ModelRow;
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
this.rows.sort((a, b) => {
|
|
383
|
-
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
|
384
|
-
return a.modelId.localeCompare(b.modelId);
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
this.rowsByProvider = new Map();
|
|
388
|
-
for (const r of this.rows) {
|
|
389
|
-
const arr = this.rowsByProvider.get(r.provider);
|
|
390
|
-
if (arr) arr.push(r);
|
|
391
|
-
else this.rowsByProvider.set(r.provider, [r]);
|
|
392
|
-
}
|
|
393
|
-
this.sortedProviders = [...this.rowsByProvider.keys()].sort((a, b) => a.localeCompare(b));
|
|
394
|
-
|
|
395
|
-
const ordered: ModelRow[] = [];
|
|
396
|
-
for (const p of this.sortedProviders) {
|
|
397
|
-
const grp = this.rowsByProvider.get(p)!;
|
|
398
|
-
grp.sort((a, b) => a.modelId.localeCompare(b.modelId));
|
|
399
|
-
ordered.push(...grp);
|
|
400
|
-
}
|
|
401
|
-
this.rows = ordered;
|
|
402
|
-
|
|
403
|
-
this.activeProviderIndex = 0;
|
|
404
|
-
this.perProviderCursor = new Map();
|
|
405
|
-
for (const p of this.sortedProviders) this.perProviderCursor.set(p, 0);
|
|
406
|
-
const initActive = this.activeProvider;
|
|
407
|
-
if (initActive) {
|
|
408
|
-
const base = this.rowsByProvider.get(initActive) ?? [];
|
|
409
|
-
this.cursorIndex = base.length > 0 ? 0 : -1;
|
|
410
|
-
} else {
|
|
411
|
-
this.cursorIndex = this.rows.length > 0 ? 0 : -1;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
this.allIds = opts.allIds ?? all.map((m) => `${m.provider}/${m.id}`);
|
|
415
|
-
this.originalEnabled = opts.enabledRaw === undefined ? undefined : [...opts.enabledRaw];
|
|
416
|
-
this.draftEnabled = opts.enabledRaw === undefined ? undefined : [...opts.enabledRaw];
|
|
417
|
-
this.pendingDiscard = false;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
getVisibleCount(): number {
|
|
421
|
-
return this.rows.filter((r) => r.visible).length;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
private isDirty(): boolean {
|
|
425
|
-
const a = this.originalEnabled;
|
|
426
|
-
const b = this.draftEnabled;
|
|
427
|
-
if (a === undefined && b === undefined) return false;
|
|
428
|
-
if (a === undefined || b === undefined) return true;
|
|
429
|
-
if (a.length !== b.length) return true;
|
|
430
|
-
const sa = [...a].sort();
|
|
431
|
-
const sb = [...b].sort();
|
|
432
|
-
return JSON.stringify(sa) !== JSON.stringify(sb);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
private rebuildVisibility(): void {
|
|
436
|
-
const draft = this.draftEnabled;
|
|
437
|
-
if (draft === undefined || draft.length === 0) {
|
|
438
|
-
for (const r of this.rows) r.visible = true;
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
const hidden = draft as string[];
|
|
442
|
-
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
443
|
-
const stripThinking = (p: string) => {
|
|
444
|
-
const idx = p.lastIndexOf(":");
|
|
445
|
-
if (idx !== -1 && VALID_THINKING.has(p.slice(idx + 1))) return p.slice(0, idx);
|
|
446
|
-
return p;
|
|
447
|
-
};
|
|
448
|
-
const matchesPattern = (ref: string, pattern: string): boolean => {
|
|
449
|
-
const pat = stripThinking(pattern);
|
|
450
|
-
const lowerRef = ref.toLowerCase();
|
|
451
|
-
const lowerPat = pat.toLowerCase();
|
|
452
|
-
const bareId = ref.split("/").slice(1).join("/").toLowerCase();
|
|
453
|
-
const hasGlob = /[*?[\]]/.test(pat);
|
|
454
|
-
if (hasGlob) {
|
|
455
|
-
const rx = globToRegExp(pat);
|
|
456
|
-
if (rx.test(ref)) return true;
|
|
457
|
-
if (rx.test(bareId)) return true;
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
return lowerRef === lowerPat || bareId === lowerPat;
|
|
461
|
-
};
|
|
462
|
-
for (const r of this.rows) {
|
|
463
|
-
let isHidden = false;
|
|
464
|
-
for (const pat of hidden) {
|
|
465
|
-
if (matchesPattern(r.id, pat)) { isHidden = true; break; }
|
|
466
|
-
}
|
|
467
|
-
r.visible = !isHidden;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
private safeFg(color: string, text: string): string {
|
|
472
|
-
try {
|
|
473
|
-
if (this.theme && typeof this.theme.fg === "function") {
|
|
474
|
-
return this.theme.fg(color, text);
|
|
475
|
-
}
|
|
476
|
-
} catch {}
|
|
477
|
-
return text;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
private safeBold(text: string): string {
|
|
481
|
-
try {
|
|
482
|
-
if (this.theme && typeof this.theme.bold === "function") {
|
|
483
|
-
return this.theme.bold(text);
|
|
484
|
-
}
|
|
485
|
-
} catch {}
|
|
486
|
-
return text;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
private safeBg(color: string, text: string): string {
|
|
490
|
-
try {
|
|
491
|
-
if (this.theme && typeof this.theme.bg === "function") {
|
|
492
|
-
return this.theme.bg(color, text);
|
|
493
|
-
}
|
|
494
|
-
} catch {}
|
|
495
|
-
return text;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
private highlight(text: string): string {
|
|
499
|
-
try {
|
|
500
|
-
if (this.theme && typeof this.theme.bg === "function") {
|
|
501
|
-
try {
|
|
502
|
-
const v = this.theme.bg("selectedBg", text);
|
|
503
|
-
if (v !== text) return v;
|
|
504
|
-
} catch {}
|
|
505
|
-
try {
|
|
506
|
-
const v2 = (this.theme as any).bg("selection", text);
|
|
507
|
-
if (v2 !== text) return v2;
|
|
508
|
-
} catch {}
|
|
509
|
-
}
|
|
510
|
-
} catch {}
|
|
511
|
-
try {
|
|
512
|
-
if (this.theme && typeof this.theme.fg === "function") {
|
|
513
|
-
return this.theme.fg("accent", text);
|
|
514
|
-
}
|
|
515
|
-
} catch {}
|
|
516
|
-
return `› ${text}`;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private isProviderCollapsed(provider: string): boolean {
|
|
520
|
-
return this.collapsed.has(provider);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
private get activeProvider(): string | undefined {
|
|
524
|
-
if (this.sortedProviders.length === 0) return undefined;
|
|
525
|
-
const idx = Math.max(0, Math.min(this.activeProviderIndex, this.sortedProviders.length - 1));
|
|
526
|
-
return this.sortedProviders[idx];
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
private get activeRows(): ModelRow[] {
|
|
530
|
-
const p = this.activeProvider;
|
|
531
|
-
if (!p) return [];
|
|
532
|
-
return this.rowsByProvider.get(p) ?? [];
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
private get rowsToShow(): ModelRow[] {
|
|
536
|
-
const base = this.activeRows;
|
|
537
|
-
if (this.filteredRows !== null) {
|
|
538
|
-
const filteredForActive = this.filteredRows.filter((r) => r.provider === this.activeProvider);
|
|
539
|
-
if (filteredForActive.length !== this.filteredRows.length) return filteredForActive;
|
|
540
|
-
return this.filteredRows;
|
|
541
|
-
}
|
|
542
|
-
return base;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
private cursorRow(): ModelRow | undefined {
|
|
546
|
-
const list = this.rowsToShow;
|
|
547
|
-
if (this.cursorIndex < 0 || this.cursorIndex >= list.length) return undefined;
|
|
548
|
-
return list[this.cursorIndex];
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
private getCurrentProvider(): string | undefined {
|
|
552
|
-
return this.activeProvider;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
private switchProvider(delta: number): void {
|
|
556
|
-
if (this.sortedProviders.length <= 1) return;
|
|
557
|
-
const curProvider = this.activeProvider;
|
|
558
|
-
if (curProvider) this.perProviderCursor.set(curProvider, this.cursorIndex);
|
|
559
|
-
let next = this.activeProviderIndex + delta;
|
|
560
|
-
next = (next + this.sortedProviders.length) % this.sortedProviders.length;
|
|
561
|
-
this.activeProviderIndex = next;
|
|
562
|
-
const nextProvider = this.activeProvider!;
|
|
563
|
-
let saved = this.perProviderCursor.get(nextProvider);
|
|
564
|
-
if (saved === undefined) saved = 0;
|
|
565
|
-
const targetRows = this.rowsToShowForProvider(nextProvider);
|
|
566
|
-
if (targetRows.length === 0) this.cursorIndex = -1;
|
|
567
|
-
else this.cursorIndex = Math.max(0, Math.min(saved, targetRows.length - 1));
|
|
568
|
-
if (this.filteredRows !== null && this.searchQuery.trim()) {
|
|
569
|
-
this.recomputeFilteredForActive();
|
|
570
|
-
const after = this.rowsToShow;
|
|
571
|
-
if (after.length === 0) this.cursorIndex = -1;
|
|
572
|
-
else this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, after.length - 1));
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
private applyVisibleBulk(action: "include" | "exclude"): void {
|
|
577
|
-
const list = this.rowsToShow;
|
|
578
|
-
if (list.length === 0) return;
|
|
579
|
-
const ids = list.map((r) => `${r.provider}/${r.modelId}` as ModelId);
|
|
580
|
-
this.draftEnabled = applyBulk(this.draftEnabled as any, this.allIds as unknown as ModelId[], ids, action) as any;
|
|
581
|
-
this.rebuildVisibility();
|
|
582
|
-
const verb = action === "include" ? "Included" : "Excluded";
|
|
583
|
-
const prov = this.activeProvider ?? "";
|
|
584
|
-
this.lastBulkMessage = `${verb} ${ids.length} in ${prov}`;
|
|
585
|
-
if (this.lastBulkTimer) { try { clearTimeout(this.lastBulkTimer); } catch {} }
|
|
586
|
-
this.lastBulkTimer = setTimeout(() => { this.lastBulkMessage = null; try { this.tui?.requestRender?.(); } catch {} }, 4000) as any;
|
|
587
|
-
if (this.cursorIndex >= this.rowsToShow.length) this.cursorIndex = Math.max(0, this.rowsToShow.length - 1);
|
|
588
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
589
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
private rowsToShowForProvider(provider: string): ModelRow[] {
|
|
593
|
-
const base = this.rowsByProvider.get(provider) ?? [];
|
|
594
|
-
if (this.filteredRows !== null) {
|
|
595
|
-
const anyForProvider = this.filteredRows.some((r) => r.provider === provider);
|
|
596
|
-
if (anyForProvider) return this.filteredRows.filter((r) => r.provider === provider);
|
|
597
|
-
if (this.searchQuery.trim()) {
|
|
598
|
-
try {
|
|
599
|
-
return fuzzyFilter(this.searchQuery, base, (r) => `${r.provider}/${r.modelId} ${r.name ?? ""}`);
|
|
600
|
-
} catch { return []; }
|
|
601
|
-
}
|
|
602
|
-
return [];
|
|
603
|
-
}
|
|
604
|
-
return base;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
private recomputeFilteredForActive(): void {
|
|
608
|
-
const q = this.searchQuery;
|
|
609
|
-
if (!q.trim()) {
|
|
610
|
-
this.filteredRows = null;
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
const base = this.activeRows;
|
|
614
|
-
try {
|
|
615
|
-
this.filteredRows = fuzzyFilter(q, base, (r) => `${r.provider}/${r.modelId} ${r.name ?? ""}`);
|
|
616
|
-
} catch {
|
|
617
|
-
this.filteredRows = null;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
private renderTabBar(width: number): string {
|
|
622
|
-
if (this.sortedProviders.length === 0) return this.safeFg("dim", " (no providers)");
|
|
623
|
-
const tabs: { styled: string; width: number }[] = [];
|
|
624
|
-
for (let i = 0; i < this.sortedProviders.length; i++) {
|
|
625
|
-
const p = this.sortedProviders[i];
|
|
626
|
-
const group = this.rowsByProvider.get(p) ?? [];
|
|
627
|
-
const vis = group.filter((r) => r.visible).length;
|
|
628
|
-
const total = group.length;
|
|
629
|
-
const count = `${vis}/${total}`;
|
|
630
|
-
const rawLabel = `${p} ${count}`;
|
|
631
|
-
let styled: string;
|
|
632
|
-
if (i === this.activeProviderIndex) {
|
|
633
|
-
const inner = ` [${rawLabel}] `;
|
|
634
|
-
let hl = inner;
|
|
635
|
-
try {
|
|
636
|
-
const bgged = this.safeBg("selection", inner);
|
|
637
|
-
if (bgged !== inner) hl = bgged;
|
|
638
|
-
else hl = this.safeFg("accent", this.safeBold(inner));
|
|
639
|
-
} catch {
|
|
640
|
-
hl = this.safeFg("accent", this.safeBold(inner));
|
|
641
|
-
}
|
|
642
|
-
styled = hl;
|
|
643
|
-
} else {
|
|
644
|
-
styled = this.safeFg("dim", ` [${rawLabel}] `);
|
|
645
|
-
}
|
|
646
|
-
const w = visibleWidthFallback(stripAnsi(styled));
|
|
647
|
-
tabs.push({ styled, width: w });
|
|
648
|
-
}
|
|
649
|
-
let totalW = tabs.reduce((s, t) => s + t.width, 0) + Math.max(0, tabs.length - 1);
|
|
650
|
-
if (totalW <= width) {
|
|
651
|
-
return tabs.map((t) => t.styled).join("");
|
|
652
|
-
}
|
|
653
|
-
let start = this.activeProviderIndex;
|
|
654
|
-
let end = this.activeProviderIndex + 1;
|
|
655
|
-
let curW = tabs[this.activeProviderIndex].width;
|
|
656
|
-
let left = this.activeProviderIndex - 1;
|
|
657
|
-
let right = this.activeProviderIndex + 1;
|
|
658
|
-
while (left >= 0 || right < tabs.length) {
|
|
659
|
-
let expanded = false;
|
|
660
|
-
if (left >= 0) {
|
|
661
|
-
const need = curW + 1 + tabs[left].width;
|
|
662
|
-
if (need <= width - 4) {
|
|
663
|
-
curW = need;
|
|
664
|
-
start = left;
|
|
665
|
-
left--;
|
|
666
|
-
expanded = true;
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
if (right < tabs.length) {
|
|
670
|
-
const need = curW + 1 + tabs[right].width;
|
|
671
|
-
if (need <= width - 4) {
|
|
672
|
-
curW = need;
|
|
673
|
-
end = right + 1;
|
|
674
|
-
right++;
|
|
675
|
-
expanded = true;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
if (!expanded) break;
|
|
679
|
-
}
|
|
680
|
-
const visible = tabs.slice(start, end);
|
|
681
|
-
let out = visible.map((t) => t.styled).join("");
|
|
682
|
-
if (start > 0) out = this.safeFg("dim", "‹ ") + out;
|
|
683
|
-
if (end < tabs.length) out = out + this.safeFg("dim", " ›");
|
|
684
|
-
return this.truncate(out, width);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
private getAvailableHeight(): number {
|
|
688
|
-
let h: number | undefined;
|
|
689
|
-
try {
|
|
690
|
-
const term = (this.tui as any)?.terminal;
|
|
691
|
-
if (term) {
|
|
692
|
-
if (typeof term.rows === "number") h = term.rows;
|
|
693
|
-
else if (typeof term.height === "number") h = term.height;
|
|
694
|
-
else if (typeof term.getSize === "function") {
|
|
695
|
-
const sz = term.getSize();
|
|
696
|
-
if (sz && typeof sz.rows === "number") h = sz.rows;
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
} catch {}
|
|
700
|
-
if (h === undefined || h === null) {
|
|
701
|
-
try {
|
|
702
|
-
const c: any = (globalThis as any).process?.stdout;
|
|
703
|
-
if (c && typeof c.rows === "number") h = c.rows;
|
|
704
|
-
} catch {}
|
|
705
|
-
}
|
|
706
|
-
if (h === undefined || h === null || h <= 0) h = 24;
|
|
707
|
-
const searchExtra = this.searchActive ? 1 : (this.filteredRows !== null && this.searchQuery ? 1 : 0);
|
|
708
|
-
// Half-screen: reserve half the terminal height for the list
|
|
709
|
-
const half = Math.floor(h / 2);
|
|
710
|
-
// Account for chrome: title + 2 dividers + tab bar + blank spacer + status + 2-line footer
|
|
711
|
-
const chrome = 8 + searchExtra;
|
|
712
|
-
const avail = Math.max(5, half - chrome);
|
|
713
|
-
return Math.min(avail, 20);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
private truncate(text: string, width: number): string {
|
|
717
|
-
try {
|
|
718
|
-
return truncateToWidthFn(text, width);
|
|
719
|
-
} catch {
|
|
720
|
-
return truncateFallback(text, width);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
render(width: number): string[] {
|
|
725
|
-
const lines: string[] = [];
|
|
726
|
-
const total = this.rows.length;
|
|
727
|
-
const visible = this.getVisibleCount();
|
|
728
|
-
const dirtyMark = this.isDirty() ? this.safeFg("warning", " ● unsaved") : "";
|
|
729
|
-
const titleText = this.safeFg("accent", this.safeBold("modelconf")) + this.safeFg("dim", ` ${total} models ${visible} visible`) + dirtyMark;
|
|
730
|
-
lines.push(this.truncate(titleText, width));
|
|
731
|
-
lines.push(this.truncate(this.safeFg("dim", "─".repeat(Math.max(0, width))), width));
|
|
732
|
-
lines.push(this.truncate(this.renderTabBar(width), width));
|
|
733
|
-
lines.push(this.truncate(this.safeFg("dim", "─".repeat(Math.max(0, width))), width));
|
|
734
|
-
if (this.searchActive) {
|
|
735
|
-
const inputWidth = Math.max(10, width - 4);
|
|
736
|
-
const rendered = this.searchInput.render(inputWidth);
|
|
737
|
-
const inputLine = rendered[0] ?? (this.searchInput as any).value ?? "";
|
|
738
|
-
const prefix = this.safeFg("accent", "/ ");
|
|
739
|
-
const line = prefix + inputLine;
|
|
740
|
-
lines.push(this.truncate(line, width));
|
|
741
|
-
} else if (this.filteredRows !== null && this.searchQuery) {
|
|
742
|
-
const hint = this.safeFg("dim", `filtered in ${this.activeProvider ?? ""}: "${this.searchQuery}" (${this.rowsToShow.length}/${this.activeRows.length}) • Esc to clear • / to search`);
|
|
743
|
-
lines.push(this.truncate(hint, width));
|
|
744
|
-
}
|
|
745
|
-
const rowsToShow = this.rowsToShow;
|
|
746
|
-
if (rowsToShow.length === 0) {
|
|
747
|
-
if (this.filteredRows !== null) {
|
|
748
|
-
const q = this.searchQuery;
|
|
749
|
-
const prov = this.activeProvider ?? "provider";
|
|
750
|
-
lines.push(this.truncate(this.safeFg("warning", ` No models match '${q}' in ${prov}`), width));
|
|
751
|
-
lines.push(this.truncate(this.safeFg("dim", " Press Esc to clear filter"), width));
|
|
752
|
-
} else if (this.sortedProviders.length === 0) {
|
|
753
|
-
lines.push(this.truncate(this.safeFg("dim", " No providers configured"), width));
|
|
754
|
-
} else {
|
|
755
|
-
lines.push(this.truncate(this.safeFg("dim", ` No models in ${this.activeProvider ?? "provider"}`), width));
|
|
756
|
-
}
|
|
757
|
-
} else {
|
|
758
|
-
const maxVisible = this.getAvailableHeight();
|
|
759
|
-
let start = 0;
|
|
760
|
-
let end = rowsToShow.length;
|
|
761
|
-
if (rowsToShow.length > maxVisible) {
|
|
762
|
-
const cursor = this.cursorIndex;
|
|
763
|
-
const half = Math.floor(maxVisible / 2);
|
|
764
|
-
start = Math.max(0, Math.min(cursor - half, rowsToShow.length - maxVisible));
|
|
765
|
-
end = Math.min(start + maxVisible, rowsToShow.length);
|
|
766
|
-
}
|
|
767
|
-
for (let idx = start; idx < end; idx++) {
|
|
768
|
-
const row = rowsToShow[idx];
|
|
769
|
-
const isCursor = this.cursorIndex === idx;
|
|
770
|
-
const mark = row.visible ? this.safeFg("success", "◉") : this.safeFg("dim", "○");
|
|
771
|
-
const labelPart = `${mark} ${row.modelId}`;
|
|
772
|
-
const dimName = row.name && row.name !== row.modelId ? this.safeFg("dim", ` ${row.name}`) : "";
|
|
773
|
-
let rawLine = `${labelPart}${dimName}`;
|
|
774
|
-
rawLine = ` ${rawLine}`;
|
|
775
|
-
let rendered: string;
|
|
776
|
-
if (isCursor) {
|
|
777
|
-
const highlighted = this.highlight(rawLine);
|
|
778
|
-
rendered = this.truncate(highlighted, width);
|
|
779
|
-
} else {
|
|
780
|
-
rendered = this.truncate(rawLine, width);
|
|
781
|
-
}
|
|
782
|
-
lines.push(rendered);
|
|
783
|
-
}
|
|
784
|
-
if (rowsToShow.length > maxVisible) {
|
|
785
|
-
const pos = this.cursorIndex >= 0 ? this.cursorIndex + 1 : 0;
|
|
786
|
-
const indicator = this.safeFg("dim", ` (${pos}/${rowsToShow.length}) in ${this.activeProvider ?? ""}`);
|
|
787
|
-
lines.push(this.truncate(indicator, width));
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
lines.push("");
|
|
791
|
-
if (this.pendingDiscard) {
|
|
792
|
-
const msg = this.safeFg("warning", "Discard changes? (y/N)") + this.safeFg("dim", " y: discard n: keep editing Esc: discard");
|
|
793
|
-
lines.push(this.truncate(msg, width));
|
|
794
|
-
} else if (this.lastBulkMessage) {
|
|
795
|
-
const isInc = this.lastBulkMessage.startsWith("Included") || this.lastBulkMessage.startsWith("Enabled");
|
|
796
|
-
const bulk = this.safeFg(isInc ? "success" : "warning", this.lastBulkMessage) + this.safeFg("dim", this.isDirty() ? " • ● unsaved — Enter save" : "");
|
|
797
|
-
lines.push(this.truncate(bulk, width));
|
|
798
|
-
} else if (this.isDirty()) {
|
|
799
|
-
lines.push(this.truncate(this.safeFg("warning", "● unsaved — press Enter to save"), width));
|
|
800
|
-
} else if (this.searchActive) {
|
|
801
|
-
lines.push(this.truncate(this.safeFg("dim", "filtering — Esc clear • Enter keep filter"), width));
|
|
802
|
-
} else {
|
|
803
|
-
const countLine = this.safeFg("dim", `${visible} visible / ${total} total`);
|
|
804
|
-
lines.push(this.truncate(countLine, width));
|
|
805
|
-
}
|
|
806
|
-
// --- Persistent keybinds footer — always visible at bottom (all binds) ---
|
|
807
|
-
const kb1 = "Tab/⇧Tab ←→ switch provider │ ↑↓/j k Home/End navigate │ Space toggle │ / filter";
|
|
808
|
-
const kb2 = "a add visible │ x hide visible │ d toggle provider │ Enter save │ Esc/q close";
|
|
809
|
-
const sep = this.safeFg("dim", " │ ");
|
|
810
|
-
// If terminal is wide enough, show in one line; otherwise split into two
|
|
811
|
-
const oneLine = `${kb1} │ ${kb2}`;
|
|
812
|
-
let footerLines: string[];
|
|
813
|
-
try {
|
|
814
|
-
const w = visibleWidthFallback(stripAnsi(oneLine));
|
|
815
|
-
if (w + 2 <= width) {
|
|
816
|
-
footerLines = [this.safeFg("dim", oneLine)];
|
|
817
|
-
} else {
|
|
818
|
-
footerLines = [this.safeFg("dim", kb1), this.safeFg("dim", kb2)];
|
|
819
|
-
}
|
|
820
|
-
} catch {
|
|
821
|
-
footerLines = [this.safeFg("dim", kb1), this.safeFg("dim", kb2)];
|
|
822
|
-
}
|
|
823
|
-
for (const fl of footerLines) lines.push(this.truncate(fl, width));
|
|
824
|
-
return lines.map((l) => this.truncate(l, width));
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
handleInput(data: string): void {
|
|
828
|
-
if (!data) return;
|
|
829
|
-
const tryKb = (keyId: string): boolean => {
|
|
830
|
-
try {
|
|
831
|
-
if (this.keybindings) {
|
|
832
|
-
if (typeof this.keybindings.matches === "function" && this.keybindings.matches(data, keyId)) return true;
|
|
833
|
-
if (typeof this.keybindings.match === "function" && this.keybindings.match(data, keyId)) return true;
|
|
834
|
-
}
|
|
835
|
-
} catch {}
|
|
836
|
-
return false;
|
|
837
|
-
};
|
|
838
|
-
if (this.pendingDiscard) {
|
|
839
|
-
if (data === "y" || data === "Y") {
|
|
840
|
-
this.pendingDiscard = false;
|
|
841
|
-
this.onDone(false);
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
if (data === "n" || data === "N") {
|
|
845
|
-
this.pendingDiscard = false;
|
|
846
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
const isEscPend = data === "\x1b" || tryKb("escape") || data.includes("\x1b[27");
|
|
850
|
-
if (isEscPend) {
|
|
851
|
-
this.pendingDiscard = false;
|
|
852
|
-
this.onDone(false);
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
if (!this.searchActive && data === "/") {
|
|
858
|
-
this.searchActive = true;
|
|
859
|
-
this.searchInput.focused = true;
|
|
860
|
-
this._focused = true;
|
|
861
|
-
if (typeof (this.searchInput as any).setValue === "function") (this.searchInput as any).setValue("");
|
|
862
|
-
else (this.searchInput as any).value = "";
|
|
863
|
-
this.searchQuery = "";
|
|
864
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
865
|
-
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
866
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
867
|
-
return;
|
|
868
|
-
}
|
|
869
|
-
if (this.searchActive) {
|
|
870
|
-
// Arrow/Home/End navigate the filtered list while typing (left/right still edit the query).
|
|
871
|
-
const navUp = data === "\x1b[A" || data === "\x1bOA" || tryKb("up");
|
|
872
|
-
const navDown = data === "\x1b[B" || data === "\x1bOB" || tryKb("down");
|
|
873
|
-
const navHome = data === "\x1b[H" || data === "\x1b[1~" || data === "\x1bOH" || tryKb("home");
|
|
874
|
-
const navEnd = data === "\x1b[F" || data === "\x1b[4~" || data === "\x1bOF" || tryKb("end");
|
|
875
|
-
if (navUp || navDown || navHome || navEnd) {
|
|
876
|
-
const navList = this.rowsToShow;
|
|
877
|
-
if (navList.length > 0) {
|
|
878
|
-
if (navUp) this.cursorIndex = (this.cursorIndex - 1 + navList.length) % navList.length;
|
|
879
|
-
else if (navDown) this.cursorIndex = (this.cursorIndex + 1) % navList.length;
|
|
880
|
-
else if (navHome) this.cursorIndex = 0;
|
|
881
|
-
else this.cursorIndex = navList.length - 1;
|
|
882
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
883
|
-
}
|
|
884
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
const isEscKitty = data.includes("\x1b[27");
|
|
888
|
-
const isEsc = data === "\x1b" || tryKb("escape") || isEscKitty;
|
|
889
|
-
const isEnter = data === "\r" || data === "\n" || data === "\r\n" || data.includes("\r") || data.includes("\n") || tryKb("enter") || tryKb("return");
|
|
890
|
-
if (data === "\t" || data === "\x1b[Z") {
|
|
891
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
if (isEsc) {
|
|
895
|
-
this.searchActive = false;
|
|
896
|
-
this.searchInput.focused = false;
|
|
897
|
-
this.filteredRows = null;
|
|
898
|
-
this.searchQuery = "";
|
|
899
|
-
if (typeof (this.searchInput as any).setValue === "function") (this.searchInput as any).setValue("");
|
|
900
|
-
else (this.searchInput as any).value = "";
|
|
901
|
-
const prov = this.activeProvider;
|
|
902
|
-
if (prov) {
|
|
903
|
-
const saved = this.perProviderCursor.get(prov);
|
|
904
|
-
const list = this.rowsToShow;
|
|
905
|
-
if (list.length === 0) this.cursorIndex = -1;
|
|
906
|
-
else this.cursorIndex = saved !== undefined ? Math.max(0, Math.min(saved, list.length - 1)) : 0;
|
|
907
|
-
} else {
|
|
908
|
-
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
909
|
-
}
|
|
910
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
911
|
-
return;
|
|
912
|
-
} else if (isEnter) {
|
|
913
|
-
this.searchActive = false;
|
|
914
|
-
this.searchInput.focused = false;
|
|
915
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
916
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
917
|
-
return;
|
|
918
|
-
} else {
|
|
919
|
-
try { (this.searchInput as any).handleInput?.(data); } catch {}
|
|
920
|
-
let q: string = "";
|
|
921
|
-
try {
|
|
922
|
-
const si: any = this.searchInput;
|
|
923
|
-
if (typeof si.getValue === "function") q = si.getValue();
|
|
924
|
-
else if (typeof si.value === "string") q = si.value;
|
|
925
|
-
else q = String(si.value ?? "");
|
|
926
|
-
} catch { q = (this.searchInput as any).value ?? ""; }
|
|
927
|
-
this.searchQuery = q;
|
|
928
|
-
if (!q.trim()) {
|
|
929
|
-
this.filteredRows = null;
|
|
930
|
-
const prov = this.activeProvider;
|
|
931
|
-
if (prov) {
|
|
932
|
-
const saved = this.perProviderCursor.get(prov);
|
|
933
|
-
this.cursorIndex = saved !== undefined ? Math.max(0, Math.min(saved, this.activeRows.length - 1)) : 0;
|
|
934
|
-
if (this.activeRows.length === 0) this.cursorIndex = -1;
|
|
935
|
-
} else {
|
|
936
|
-
this.cursorIndex = 0;
|
|
937
|
-
}
|
|
938
|
-
} else {
|
|
939
|
-
try {
|
|
940
|
-
const base = this.activeRows;
|
|
941
|
-
this.filteredRows = fuzzyFilter(q, base, (r) => `${r.provider}/${r.modelId} ${r.name ?? ""}`);
|
|
942
|
-
} catch {
|
|
943
|
-
this.filteredRows = null;
|
|
944
|
-
}
|
|
945
|
-
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
946
|
-
}
|
|
947
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
if (data === "\t" || data === "\x09") {
|
|
952
|
-
this.switchProvider(1);
|
|
953
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
954
|
-
return;
|
|
955
|
-
}
|
|
956
|
-
if (data === "\x1b[Z") {
|
|
957
|
-
this.switchProvider(-1);
|
|
958
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
if (!this.searchActive && (data === "d" || data === "D")) {
|
|
962
|
-
const active = this.activeRows;
|
|
963
|
-
if (active.length === 0) return;
|
|
964
|
-
const vis = active.filter((r) => r.visible).length;
|
|
965
|
-
const allVisible = vis === active.length;
|
|
966
|
-
const ids = active.map((r) => `${r.provider}/${r.modelId}` as ModelId);
|
|
967
|
-
if (allVisible) {
|
|
968
|
-
this.draftEnabled = applyBulk(this.draftEnabled as any, this.allIds as unknown as ModelId[], ids, "exclude") as any;
|
|
969
|
-
this.lastBulkMessage = `Disabled all ${ids.length} in ${this.activeProvider ?? "provider"}`;
|
|
970
|
-
} else {
|
|
971
|
-
this.draftEnabled = applyBulk(this.draftEnabled as any, this.allIds as unknown as ModelId[], ids, "include") as any;
|
|
972
|
-
this.lastBulkMessage = `Enabled all ${ids.length} in ${this.activeProvider ?? "provider"}`;
|
|
973
|
-
}
|
|
974
|
-
this.rebuildVisibility();
|
|
975
|
-
if (this.lastBulkTimer) { try { clearTimeout(this.lastBulkTimer); } catch {} }
|
|
976
|
-
this.lastBulkTimer = setTimeout(() => { this.lastBulkMessage = null; try { this.tui?.requestRender?.(); } catch {} }, 4000) as any;
|
|
977
|
-
if (this.cursorIndex >= this.rowsToShow.length) this.cursorIndex = Math.max(0, this.rowsToShow.length - 1);
|
|
978
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
979
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
if (!this.searchActive && (data === "a" || data === "A")) {
|
|
983
|
-
this.applyVisibleBulk("include");
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
if (!this.searchActive && (data === "x" || data === "X")) {
|
|
987
|
-
this.applyVisibleBulk("exclude");
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
const isEscKitty = data.includes("\x1b[27");
|
|
991
|
-
const isEsc = data === "\x1b" || tryKb("escape") || isEscKitty;
|
|
992
|
-
const isQ = data === "q" || data === "Q";
|
|
993
|
-
if ((isEsc || isQ) && this.filteredRows !== null) {
|
|
994
|
-
this.filteredRows = null;
|
|
995
|
-
this.searchQuery = "";
|
|
996
|
-
if (typeof (this.searchInput as any).setValue === "function") (this.searchInput as any).setValue("");
|
|
997
|
-
else (this.searchInput as any).value = "";
|
|
998
|
-
this.searchInput.focused = false;
|
|
999
|
-
this.searchActive = false;
|
|
1000
|
-
const prov = this.activeProvider;
|
|
1001
|
-
if (prov) {
|
|
1002
|
-
const saved = this.perProviderCursor.get(prov);
|
|
1003
|
-
const list = this.activeRows;
|
|
1004
|
-
if (list.length === 0) this.cursorIndex = -1;
|
|
1005
|
-
else this.cursorIndex = saved !== undefined ? Math.max(0, Math.min(saved, list.length - 1)) : 0;
|
|
1006
|
-
} else {
|
|
1007
|
-
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
1008
|
-
}
|
|
1009
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
if (isEsc || isQ) {
|
|
1013
|
-
if (this.isDirty()) {
|
|
1014
|
-
this.pendingDiscard = true;
|
|
1015
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1016
|
-
return;
|
|
1017
|
-
}
|
|
1018
|
-
this.onDone(false);
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
if (data === " ") {
|
|
1022
|
-
const row = this.cursorRow();
|
|
1023
|
-
if (!row) return;
|
|
1024
|
-
const modelId = `${row.provider}/${row.modelId}` as ModelId;
|
|
1025
|
-
// Hide/show operates on the full model list (this.allIds is the full registry),
|
|
1026
|
-
// even though the view is scoped to configured providers. This ensures toggling
|
|
1027
|
-
// a model affects the main full enabledModels list, not just the scoped subset.
|
|
1028
|
-
this.draftEnabled = toggleInEnabled(modelId, this.draftEnabled, this.allIds as ModelId[]);
|
|
1029
|
-
this.rebuildVisibility();
|
|
1030
|
-
this.pendingDiscard = false;
|
|
1031
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1032
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1033
|
-
return;
|
|
1034
|
-
}
|
|
1035
|
-
const isEnter = data === "\r" || data === "\n" || data === "\r\n" || tryKb("enter") || tryKb("return");
|
|
1036
|
-
const isEnterRaw = isEnter || data.includes("\r") || data.includes("\n");
|
|
1037
|
-
if (isEnterRaw) {
|
|
1038
|
-
const toSave = this.draftEnabled;
|
|
1039
|
-
Promise.resolve(this._onPersist(toSave as any)).then(() => {
|
|
1040
|
-
this.onDone(true);
|
|
1041
|
-
}).catch(() => {
|
|
1042
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1043
|
-
});
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
// Left/Right also switch provider (alternative to Tab/Shift-Tab) when not in search
|
|
1047
|
-
const isLeft = data === "\x1b[D" || data === "\x1bOD" || tryKb("left");
|
|
1048
|
-
const isRight = data === "\x1b[C" || data === "\x1bOC" || tryKb("right");
|
|
1049
|
-
if (isLeft) {
|
|
1050
|
-
this.switchProvider(-1);
|
|
1051
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
if (isRight) {
|
|
1055
|
-
this.switchProvider(1);
|
|
1056
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
let handled = false;
|
|
1060
|
-
const isUp = data === "k" || data === "\x1b[A" || data === "\x1bOA" || tryKb("up") || tryKb("k");
|
|
1061
|
-
const isDown = data === "j" || data === "\x1b[B" || data === "\x1bOB" || tryKb("down") || tryKb("j");
|
|
1062
|
-
const isHome = data === "g" || data === "\x1b[H" || data === "\x1b[1~" || data === "\x1bOH" || tryKb("home") || tryKb("g");
|
|
1063
|
-
const isEnd = data === "G" || data === "\x1b[F" || data === "\x1b[4~" || data === "\x1bOF" || tryKb("end") || tryKb("G");
|
|
1064
|
-
const navList = this.rowsToShow;
|
|
1065
|
-
if (isUp) {
|
|
1066
|
-
if (navList.length > 0) {
|
|
1067
|
-
const next = (this.cursorIndex - 1 + navList.length) % navList.length;
|
|
1068
|
-
this.cursorIndex = next;
|
|
1069
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1070
|
-
handled = true;
|
|
1071
|
-
}
|
|
1072
|
-
} else if (isDown) {
|
|
1073
|
-
if (navList.length > 0) {
|
|
1074
|
-
const next = (this.cursorIndex + 1) % navList.length;
|
|
1075
|
-
this.cursorIndex = next;
|
|
1076
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1077
|
-
handled = true;
|
|
1078
|
-
}
|
|
1079
|
-
} else if (isHome) {
|
|
1080
|
-
if (navList.length > 0) {
|
|
1081
|
-
this.cursorIndex = 0;
|
|
1082
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1083
|
-
handled = true;
|
|
1084
|
-
}
|
|
1085
|
-
} else if (isEnd) {
|
|
1086
|
-
if (navList.length > 0) {
|
|
1087
|
-
this.cursorIndex = navList.length - 1;
|
|
1088
|
-
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1089
|
-
handled = true;
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
if (handled) {
|
|
1093
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
invalidate(): void {
|
|
1098
|
-
try { this.tui?.requestRender?.(); } catch {}
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1
|
+
import { applyBulk, toggleInEnabled } from "../persistence.js";
|
|
2
|
+
import type { ModelId } from "../persistence.js";
|
|
3
|
+
import { globToRegExp } from "../glob.js";
|
|
4
|
+
import { fuzzyFilter } from "../fuzzy.js";
|
|
5
|
+
// Minimal Component type to avoid requiring @earendil-works/pi-tui at build time.
|
|
6
|
+
// We mirror the pi-tui Component contract used by ctx.ui.custom.
|
|
7
|
+
export interface Component {
|
|
8
|
+
render(width: number): string[];
|
|
9
|
+
handleInput?(data: string): void;
|
|
10
|
+
invalidate(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Focusable contract per docs/tui.md — propagate focused to child Input
|
|
14
|
+
export interface Focusable {
|
|
15
|
+
focused: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---- local helpers for width/truncation (fallback when pi-tui not available) ----
|
|
19
|
+
|
|
20
|
+
function stripAnsi(s: string): string {
|
|
21
|
+
// strip CSI ...m, OSC 8 hyperlink, and APC cursor marker
|
|
22
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\]8;;.*?\x1b\\/g, "").replace(/\x1b_pi:c\x07/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function visibleWidthFallback(s: string): number {
|
|
26
|
+
const stripped = stripAnsi(s);
|
|
27
|
+
let w = 0;
|
|
28
|
+
for (const ch of stripped) {
|
|
29
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
30
|
+
// crude wide char detection: CJK + emoji ranges
|
|
31
|
+
if (cp >= 0x1100 && (
|
|
32
|
+
cp <= 0x115F || cp === 0x2329 || cp === 0x232A ||
|
|
33
|
+
(cp >= 0x2E80 && cp <= 0xA4CF && cp !== 0x303F) ||
|
|
34
|
+
(cp >= 0xAC00 && cp <= 0xD7A3) ||
|
|
35
|
+
(cp >= 0xF900 && cp <= 0xFAFF) ||
|
|
36
|
+
(cp >= 0xFE10 && cp <= 0xFE19) ||
|
|
37
|
+
(cp >= 0xFE30 && cp <= 0xFE6F) ||
|
|
38
|
+
(cp >= 0xFF00 && cp <= 0xFF60) ||
|
|
39
|
+
(cp >= 0xFFE0 && cp <= 0xFFE6)
|
|
40
|
+
)) {
|
|
41
|
+
w += 2;
|
|
42
|
+
} else {
|
|
43
|
+
w += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return w;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
|
50
|
+
if (str[pos] !== "\x1b") return null;
|
|
51
|
+
const next = str[pos + 1];
|
|
52
|
+
if (next === "[") {
|
|
53
|
+
let i = pos + 2;
|
|
54
|
+
while (i < str.length) {
|
|
55
|
+
const c = str[i];
|
|
56
|
+
if ((c >= "0" && c <= "9") || c === ";" || c === ":" || c === "?" ) { i++; continue; }
|
|
57
|
+
// final char
|
|
58
|
+
i++;
|
|
59
|
+
return { code: str.slice(pos, i), length: i - pos };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (next === "]") {
|
|
64
|
+
const end = str.indexOf("\x1b\\", pos);
|
|
65
|
+
if (end !== -1) return { code: str.slice(pos, end + 2), length: end + 2 - pos };
|
|
66
|
+
const bel = str.indexOf("\x07", pos);
|
|
67
|
+
if (bel !== -1) return { code: str.slice(pos, bel + 1), length: bel + 1 - pos };
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (str.startsWith("\x1b_pi:c\x07", pos)) return { code: "\x1b_pi:c\x07", length: 7 };
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncateFallback(text: string, maxWidth: number, ellipsis = "..."): string {
|
|
75
|
+
if (maxWidth <= 0) return "";
|
|
76
|
+
const vw = visibleWidthFallback(text);
|
|
77
|
+
if (vw <= maxWidth) return text;
|
|
78
|
+
const ellW = visibleWidthFallback(ellipsis);
|
|
79
|
+
const target = maxWidth - ellW;
|
|
80
|
+
if (target <= 0) {
|
|
81
|
+
// truncate ellipsis itself if needed
|
|
82
|
+
let out = "";
|
|
83
|
+
let w = 0;
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < text.length) {
|
|
86
|
+
const ansi = extractAnsiCode(text, i);
|
|
87
|
+
if (ansi) { out += ansi.code; i += ansi.length; continue; }
|
|
88
|
+
// grapheme: take one codepoint
|
|
89
|
+
const ch = text[i];
|
|
90
|
+
// handle surrogate pair
|
|
91
|
+
let grapheme = ch;
|
|
92
|
+
if (ch.charCodeAt(0) >= 0xD800 && ch.charCodeAt(0) <= 0xDBFF && i + 1 < text.length) {
|
|
93
|
+
grapheme = text.slice(i, i + 2);
|
|
94
|
+
}
|
|
95
|
+
const gw = visibleWidthFallback(grapheme);
|
|
96
|
+
if (w + gw > maxWidth) break;
|
|
97
|
+
out += grapheme;
|
|
98
|
+
w += gw;
|
|
99
|
+
i += grapheme.length;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
let out = "";
|
|
104
|
+
let w = 0;
|
|
105
|
+
let i = 0;
|
|
106
|
+
let pendingAnsi = "";
|
|
107
|
+
while (i < text.length) {
|
|
108
|
+
const ansi = extractAnsiCode(text, i);
|
|
109
|
+
if (ansi) { pendingAnsi += ansi.code; out += ansi.code; i += ansi.length; continue; }
|
|
110
|
+
let grapheme = text[i];
|
|
111
|
+
if (grapheme.charCodeAt(0) >= 0xD800 && grapheme.charCodeAt(0) <= 0xDBFF && i + 1 < text.length) {
|
|
112
|
+
grapheme = text.slice(i, i + 2);
|
|
113
|
+
}
|
|
114
|
+
const gw = visibleWidthFallback(grapheme);
|
|
115
|
+
if (w + gw > target) break;
|
|
116
|
+
out += grapheme;
|
|
117
|
+
w += gw;
|
|
118
|
+
i += grapheme.length;
|
|
119
|
+
}
|
|
120
|
+
out += ellipsis;
|
|
121
|
+
// Append pending ansi resets? naive: ensure reset at end not needed
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Try to use pi-tui's truncate if available at runtime (optional). For compile-time we use fallback.
|
|
126
|
+
let truncateToWidthFn: (text: string, maxWidth: number, ellipsis?: string, pad?: boolean) => string = truncateFallback;
|
|
127
|
+
try {
|
|
128
|
+
// @ts-ignore - optional dynamic import may fail at build if module not installed; fallback is fine
|
|
129
|
+
// We keep fallback as default; runtime override attempted via dynamic import is not critical
|
|
130
|
+
} catch {}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Simple Input fallback — mimics @earendil-works/pi-tui Input for search mode
|
|
134
|
+
// Handles printable chars, backspace (\x7f, \x08), delete, left/right arrows.
|
|
135
|
+
// Propagates focused flag per Focusable contract. Fallback when pi-tui Input not resolvable.
|
|
136
|
+
|
|
137
|
+
class SimpleSearchInput implements Component, Focusable {
|
|
138
|
+
value = "";
|
|
139
|
+
focused = false;
|
|
140
|
+
private cursor = 0;
|
|
141
|
+
// Keep for compatibility with pi-tui Input shape
|
|
142
|
+
getValue(): string { return this.value; }
|
|
143
|
+
setValue(v: string): void { this.value = v; this.cursor = v.length; }
|
|
144
|
+
|
|
145
|
+
handleInput(data: string): void {
|
|
146
|
+
if (!data) return;
|
|
147
|
+
// Handle bracketed paste markers — strip and insert content
|
|
148
|
+
if (data.includes("\x1b[200~")) {
|
|
149
|
+
const start = data.indexOf("\x1b[200~");
|
|
150
|
+
const end = data.indexOf("\x1b[201~");
|
|
151
|
+
if (end !== -1) {
|
|
152
|
+
const paste = data.substring(start + 6, end);
|
|
153
|
+
this.insertAtCursor(paste);
|
|
154
|
+
const remaining = data.slice(end + 6);
|
|
155
|
+
if (remaining) this.handleInput(remaining);
|
|
156
|
+
return;
|
|
157
|
+
} else {
|
|
158
|
+
// incomplete paste marker — strip marker and insert rest
|
|
159
|
+
data = data.replace("\x1b[200~", "");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (data.includes("\x1b[201~")) {
|
|
163
|
+
data = data.replace("\x1b[201~", "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Special keys — handle one at a time if data contains escapes
|
|
167
|
+
// Backspace: \x7f or \x08 or \b
|
|
168
|
+
if (data === "\x7f" || data === "\x08" || data === "\b") {
|
|
169
|
+
this.handleBackspace();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Delete (forward): \x1b[3~
|
|
173
|
+
if (data === "\x1b[3~") {
|
|
174
|
+
this.handleDelete();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Arrows for cursor movement within input
|
|
178
|
+
if (data === "\x1b[D" || data === "\x1bOD") { // left
|
|
179
|
+
if (this.cursor > 0) this.cursor--;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (data === "\x1b[C" || data === "\x1bOC") { // right
|
|
183
|
+
if (this.cursor < this.value.length) this.cursor++;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (data === "\x1b[H" || data === "\x1bOH" || data === "\x1b[1~") { // home
|
|
187
|
+
this.cursor = 0;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (data === "\x1b[F" || data === "\x1bOF" || data === "\x1b[4~") { // end
|
|
191
|
+
this.cursor = this.value.length;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Ctrl-U (kill line)
|
|
195
|
+
if (data === "\x15") {
|
|
196
|
+
this.value = "";
|
|
197
|
+
this.cursor = 0;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Ctrl-W (delete word backwards)
|
|
201
|
+
if (data === "\x17") {
|
|
202
|
+
this.deleteWordBackwards();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// If data looks like an escape sequence (arrow, etc.) not handled above and length >1, ignore
|
|
206
|
+
if (data.startsWith("\x1b") && data.length > 1) {
|
|
207
|
+
// Unknown escape — ignore (avoid inserting ESC into query)
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Ctrl-C / Ctrl-D etc — ignore
|
|
211
|
+
if (data.length === 1 && data.charCodeAt(0) < 0x20) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Printable insertion — insert at cursor (support multi-char paste without bracket markers)
|
|
215
|
+
// Filter out non-printable control chars
|
|
216
|
+
let insert = "";
|
|
217
|
+
for (const ch of data) {
|
|
218
|
+
const code = ch.charCodeAt(0);
|
|
219
|
+
if (code >= 0x20 && code !== 0x7f) insert += ch;
|
|
220
|
+
}
|
|
221
|
+
if (insert) this.insertAtCursor(insert);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private insertAtCursor(text: string): void {
|
|
225
|
+
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
|
226
|
+
this.cursor += text.length;
|
|
227
|
+
}
|
|
228
|
+
private handleBackspace(): void {
|
|
229
|
+
if (this.cursor > 0) {
|
|
230
|
+
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
|
|
231
|
+
this.cursor--;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
private handleDelete(): void {
|
|
235
|
+
if (this.cursor < this.value.length) {
|
|
236
|
+
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
private deleteWordBackwards(): void {
|
|
240
|
+
if (this.cursor === 0) return;
|
|
241
|
+
let pos = this.cursor;
|
|
242
|
+
while (pos > 0 && this.value[pos - 1] === " ") pos--;
|
|
243
|
+
while (pos > 0 && this.value[pos - 1] !== " ") pos--;
|
|
244
|
+
this.value = this.value.slice(0, pos) + this.value.slice(this.cursor);
|
|
245
|
+
this.cursor = pos;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
render(width: number): string[] {
|
|
249
|
+
// Render with cursor marker when focused. Use \x1b_pi:c\x07 marker for IME if needed? Simple underscore.
|
|
250
|
+
// Show placeholder when empty and not focused? When focused, show cursor.
|
|
251
|
+
const cursorMark = "\x1b_pi:c\x07";
|
|
252
|
+
let display: string;
|
|
253
|
+
if (this.focused) {
|
|
254
|
+
const before = this.value.slice(0, this.cursor);
|
|
255
|
+
const after = this.value.slice(this.cursor);
|
|
256
|
+
// Insert cursor marker + visual block; terminal will show cursor at marker
|
|
257
|
+
display = before + cursorMark + (after.length > 0 ? after : " ");
|
|
258
|
+
// Also truncate to width with visibleWidth handling? Keep simple truncation
|
|
259
|
+
// Use truncateFallback to keep ANSI safe (cursorMark is ANSI-like)
|
|
260
|
+
if (visibleWidthFallback(display) > width) {
|
|
261
|
+
// simple scroll: show window around cursor
|
|
262
|
+
const half = Math.floor(width / 2);
|
|
263
|
+
let start = Math.max(0, this.cursor - half);
|
|
264
|
+
// crude slice
|
|
265
|
+
let slice = this.value.slice(start, start + width);
|
|
266
|
+
const curInSlice = this.cursor - start;
|
|
267
|
+
display = slice.slice(0, curInSlice) + cursorMark + slice.slice(curInSlice);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
display = this.value;
|
|
271
|
+
}
|
|
272
|
+
// Apply truncate for width
|
|
273
|
+
const line = truncateFallback(display, width);
|
|
274
|
+
return [line];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
invalidate(): void {}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
export interface ModelRow {
|
|
283
|
+
id: string; // "provider/modelId"
|
|
284
|
+
provider: string;
|
|
285
|
+
modelId: string;
|
|
286
|
+
visible: boolean;
|
|
287
|
+
name?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ModelConfViewOpts {
|
|
291
|
+
tui: any;
|
|
292
|
+
theme: any;
|
|
293
|
+
keybindings: any;
|
|
294
|
+
allModels: Array<{ provider: string; id: string; name?: string }>;
|
|
295
|
+
visibleSet: Set<string>;
|
|
296
|
+
enabledRaw: string[] | undefined;
|
|
297
|
+
onDone: (saved: boolean) => void;
|
|
298
|
+
onPersist: (nextEnabled: string[] | undefined) => Promise<void>;
|
|
299
|
+
allIds?: string[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type ContentEntry =
|
|
303
|
+
| { kind: "header"; provider: string }
|
|
304
|
+
| { kind: "row"; row: ModelRow; flatIndex: number };
|
|
305
|
+
|
|
306
|
+
export class ModelConfView implements Component, Focusable {
|
|
307
|
+
private tui: any;
|
|
308
|
+
private theme: any;
|
|
309
|
+
private keybindings: any;
|
|
310
|
+
private onDone: (saved: boolean) => void;
|
|
311
|
+
private _onPersist: (next: string[] | undefined) => Promise<void>;
|
|
312
|
+
|
|
313
|
+
private rows: ModelRow[] = [];
|
|
314
|
+
private sortedProviders: string[] = [];
|
|
315
|
+
private rowsByProvider: Map<string, ModelRow[]> = new Map();
|
|
316
|
+
private cursorIndex: number = 0;
|
|
317
|
+
private collapsed: Set<string> = new Set();
|
|
318
|
+
private originalEnabled: string[] | undefined;
|
|
319
|
+
private draftEnabled: string[] | undefined;
|
|
320
|
+
private allIds: string[] = [];
|
|
321
|
+
private pendingDiscard = false;
|
|
322
|
+
|
|
323
|
+
private activeProviderIndex: number = 0;
|
|
324
|
+
private perProviderCursor: Map<string, number> = new Map();
|
|
325
|
+
|
|
326
|
+
private searchActive = false;
|
|
327
|
+
private searchQuery = "";
|
|
328
|
+
private filteredRows: ModelRow[] | null = null;
|
|
329
|
+
private searchInput: SimpleSearchInput;
|
|
330
|
+
private _focused = false;
|
|
331
|
+
get focused(): boolean { return this._focused; }
|
|
332
|
+
set focused(v: boolean) {
|
|
333
|
+
this._focused = v;
|
|
334
|
+
if (this.searchInput) this.searchInput.focused = v && this.searchActive;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private lastBulkMessage: string | null = null;
|
|
338
|
+
private lastBulkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
339
|
+
|
|
340
|
+
constructor(opts: ModelConfViewOpts) {
|
|
341
|
+
this.tui = opts.tui;
|
|
342
|
+
this.theme = opts.theme;
|
|
343
|
+
this.keybindings = opts.keybindings;
|
|
344
|
+
this.onDone = opts.onDone;
|
|
345
|
+
this._onPersist = opts.onPersist;
|
|
346
|
+
|
|
347
|
+
this.searchInput = new SimpleSearchInput();
|
|
348
|
+
try {
|
|
349
|
+
const req: any = (globalThis as any).require ?? (typeof require !== "undefined" ? require : undefined);
|
|
350
|
+
if (req) {
|
|
351
|
+
let mod: any;
|
|
352
|
+
try { mod = req("@earendil-works/pi-tui"); } catch { try { mod = req("@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui"); } catch {} }
|
|
353
|
+
if (mod?.Input) {
|
|
354
|
+
const real = new mod.Input();
|
|
355
|
+
if (typeof real.value === "string" || typeof real.getValue === "function") {
|
|
356
|
+
const wrapped: any = real;
|
|
357
|
+
if (!("value" in wrapped) || typeof wrapped.value !== "string") {
|
|
358
|
+
Object.defineProperty(wrapped, "value", {
|
|
359
|
+
get() { return typeof wrapped.getValue === "function" ? wrapped.getValue() : wrapped._value ?? ""; },
|
|
360
|
+
set(v: string) { if (typeof wrapped.setValue === "function") wrapped.setValue(v); else wrapped._value = v; },
|
|
361
|
+
configurable: true,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
this.searchInput = wrapped;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch {}
|
|
369
|
+
|
|
370
|
+
const all = opts.allModels ?? [];
|
|
371
|
+
this.rows = all.map((m) => {
|
|
372
|
+
const id = `${m.provider}/${m.id}`;
|
|
373
|
+
return {
|
|
374
|
+
id,
|
|
375
|
+
provider: m.provider,
|
|
376
|
+
modelId: m.id,
|
|
377
|
+
visible: opts.visibleSet.has(id),
|
|
378
|
+
name: m.name ?? m.id,
|
|
379
|
+
} as ModelRow;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
this.rows.sort((a, b) => {
|
|
383
|
+
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
|
384
|
+
return a.modelId.localeCompare(b.modelId);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
this.rowsByProvider = new Map();
|
|
388
|
+
for (const r of this.rows) {
|
|
389
|
+
const arr = this.rowsByProvider.get(r.provider);
|
|
390
|
+
if (arr) arr.push(r);
|
|
391
|
+
else this.rowsByProvider.set(r.provider, [r]);
|
|
392
|
+
}
|
|
393
|
+
this.sortedProviders = [...this.rowsByProvider.keys()].sort((a, b) => a.localeCompare(b));
|
|
394
|
+
|
|
395
|
+
const ordered: ModelRow[] = [];
|
|
396
|
+
for (const p of this.sortedProviders) {
|
|
397
|
+
const grp = this.rowsByProvider.get(p)!;
|
|
398
|
+
grp.sort((a, b) => a.modelId.localeCompare(b.modelId));
|
|
399
|
+
ordered.push(...grp);
|
|
400
|
+
}
|
|
401
|
+
this.rows = ordered;
|
|
402
|
+
|
|
403
|
+
this.activeProviderIndex = 0;
|
|
404
|
+
this.perProviderCursor = new Map();
|
|
405
|
+
for (const p of this.sortedProviders) this.perProviderCursor.set(p, 0);
|
|
406
|
+
const initActive = this.activeProvider;
|
|
407
|
+
if (initActive) {
|
|
408
|
+
const base = this.rowsByProvider.get(initActive) ?? [];
|
|
409
|
+
this.cursorIndex = base.length > 0 ? 0 : -1;
|
|
410
|
+
} else {
|
|
411
|
+
this.cursorIndex = this.rows.length > 0 ? 0 : -1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
this.allIds = opts.allIds ?? all.map((m) => `${m.provider}/${m.id}`);
|
|
415
|
+
this.originalEnabled = opts.enabledRaw === undefined ? undefined : [...opts.enabledRaw];
|
|
416
|
+
this.draftEnabled = opts.enabledRaw === undefined ? undefined : [...opts.enabledRaw];
|
|
417
|
+
this.pendingDiscard = false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
getVisibleCount(): number {
|
|
421
|
+
return this.rows.filter((r) => r.visible).length;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private isDirty(): boolean {
|
|
425
|
+
const a = this.originalEnabled;
|
|
426
|
+
const b = this.draftEnabled;
|
|
427
|
+
if (a === undefined && b === undefined) return false;
|
|
428
|
+
if (a === undefined || b === undefined) return true;
|
|
429
|
+
if (a.length !== b.length) return true;
|
|
430
|
+
const sa = [...a].sort();
|
|
431
|
+
const sb = [...b].sort();
|
|
432
|
+
return JSON.stringify(sa) !== JSON.stringify(sb);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private rebuildVisibility(): void {
|
|
436
|
+
const draft = this.draftEnabled;
|
|
437
|
+
if (draft === undefined || draft.length === 0) {
|
|
438
|
+
for (const r of this.rows) r.visible = true;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const hidden = draft as string[];
|
|
442
|
+
const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
443
|
+
const stripThinking = (p: string) => {
|
|
444
|
+
const idx = p.lastIndexOf(":");
|
|
445
|
+
if (idx !== -1 && VALID_THINKING.has(p.slice(idx + 1))) return p.slice(0, idx);
|
|
446
|
+
return p;
|
|
447
|
+
};
|
|
448
|
+
const matchesPattern = (ref: string, pattern: string): boolean => {
|
|
449
|
+
const pat = stripThinking(pattern);
|
|
450
|
+
const lowerRef = ref.toLowerCase();
|
|
451
|
+
const lowerPat = pat.toLowerCase();
|
|
452
|
+
const bareId = ref.split("/").slice(1).join("/").toLowerCase();
|
|
453
|
+
const hasGlob = /[*?[\]]/.test(pat);
|
|
454
|
+
if (hasGlob) {
|
|
455
|
+
const rx = globToRegExp(pat);
|
|
456
|
+
if (rx.test(ref)) return true;
|
|
457
|
+
if (rx.test(bareId)) return true;
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
return lowerRef === lowerPat || bareId === lowerPat;
|
|
461
|
+
};
|
|
462
|
+
for (const r of this.rows) {
|
|
463
|
+
let isHidden = false;
|
|
464
|
+
for (const pat of hidden) {
|
|
465
|
+
if (matchesPattern(r.id, pat)) { isHidden = true; break; }
|
|
466
|
+
}
|
|
467
|
+
r.visible = !isHidden;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private safeFg(color: string, text: string): string {
|
|
472
|
+
try {
|
|
473
|
+
if (this.theme && typeof this.theme.fg === "function") {
|
|
474
|
+
return this.theme.fg(color, text);
|
|
475
|
+
}
|
|
476
|
+
} catch {}
|
|
477
|
+
return text;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private safeBold(text: string): string {
|
|
481
|
+
try {
|
|
482
|
+
if (this.theme && typeof this.theme.bold === "function") {
|
|
483
|
+
return this.theme.bold(text);
|
|
484
|
+
}
|
|
485
|
+
} catch {}
|
|
486
|
+
return text;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private safeBg(color: string, text: string): string {
|
|
490
|
+
try {
|
|
491
|
+
if (this.theme && typeof this.theme.bg === "function") {
|
|
492
|
+
return this.theme.bg(color, text);
|
|
493
|
+
}
|
|
494
|
+
} catch {}
|
|
495
|
+
return text;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private highlight(text: string): string {
|
|
499
|
+
try {
|
|
500
|
+
if (this.theme && typeof this.theme.bg === "function") {
|
|
501
|
+
try {
|
|
502
|
+
const v = this.theme.bg("selectedBg", text);
|
|
503
|
+
if (v !== text) return v;
|
|
504
|
+
} catch {}
|
|
505
|
+
try {
|
|
506
|
+
const v2 = (this.theme as any).bg("selection", text);
|
|
507
|
+
if (v2 !== text) return v2;
|
|
508
|
+
} catch {}
|
|
509
|
+
}
|
|
510
|
+
} catch {}
|
|
511
|
+
try {
|
|
512
|
+
if (this.theme && typeof this.theme.fg === "function") {
|
|
513
|
+
return this.theme.fg("accent", text);
|
|
514
|
+
}
|
|
515
|
+
} catch {}
|
|
516
|
+
return `› ${text}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private isProviderCollapsed(provider: string): boolean {
|
|
520
|
+
return this.collapsed.has(provider);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private get activeProvider(): string | undefined {
|
|
524
|
+
if (this.sortedProviders.length === 0) return undefined;
|
|
525
|
+
const idx = Math.max(0, Math.min(this.activeProviderIndex, this.sortedProviders.length - 1));
|
|
526
|
+
return this.sortedProviders[idx];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private get activeRows(): ModelRow[] {
|
|
530
|
+
const p = this.activeProvider;
|
|
531
|
+
if (!p) return [];
|
|
532
|
+
return this.rowsByProvider.get(p) ?? [];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private get rowsToShow(): ModelRow[] {
|
|
536
|
+
const base = this.activeRows;
|
|
537
|
+
if (this.filteredRows !== null) {
|
|
538
|
+
const filteredForActive = this.filteredRows.filter((r) => r.provider === this.activeProvider);
|
|
539
|
+
if (filteredForActive.length !== this.filteredRows.length) return filteredForActive;
|
|
540
|
+
return this.filteredRows;
|
|
541
|
+
}
|
|
542
|
+
return base;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private cursorRow(): ModelRow | undefined {
|
|
546
|
+
const list = this.rowsToShow;
|
|
547
|
+
if (this.cursorIndex < 0 || this.cursorIndex >= list.length) return undefined;
|
|
548
|
+
return list[this.cursorIndex];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private getCurrentProvider(): string | undefined {
|
|
552
|
+
return this.activeProvider;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private switchProvider(delta: number): void {
|
|
556
|
+
if (this.sortedProviders.length <= 1) return;
|
|
557
|
+
const curProvider = this.activeProvider;
|
|
558
|
+
if (curProvider) this.perProviderCursor.set(curProvider, this.cursorIndex);
|
|
559
|
+
let next = this.activeProviderIndex + delta;
|
|
560
|
+
next = (next + this.sortedProviders.length) % this.sortedProviders.length;
|
|
561
|
+
this.activeProviderIndex = next;
|
|
562
|
+
const nextProvider = this.activeProvider!;
|
|
563
|
+
let saved = this.perProviderCursor.get(nextProvider);
|
|
564
|
+
if (saved === undefined) saved = 0;
|
|
565
|
+
const targetRows = this.rowsToShowForProvider(nextProvider);
|
|
566
|
+
if (targetRows.length === 0) this.cursorIndex = -1;
|
|
567
|
+
else this.cursorIndex = Math.max(0, Math.min(saved, targetRows.length - 1));
|
|
568
|
+
if (this.filteredRows !== null && this.searchQuery.trim()) {
|
|
569
|
+
this.recomputeFilteredForActive();
|
|
570
|
+
const after = this.rowsToShow;
|
|
571
|
+
if (after.length === 0) this.cursorIndex = -1;
|
|
572
|
+
else this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, after.length - 1));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private applyVisibleBulk(action: "include" | "exclude"): void {
|
|
577
|
+
const list = this.rowsToShow;
|
|
578
|
+
if (list.length === 0) return;
|
|
579
|
+
const ids = list.map((r) => `${r.provider}/${r.modelId}` as ModelId);
|
|
580
|
+
this.draftEnabled = applyBulk(this.draftEnabled as any, this.allIds as unknown as ModelId[], ids, action) as any;
|
|
581
|
+
this.rebuildVisibility();
|
|
582
|
+
const verb = action === "include" ? "Included" : "Excluded";
|
|
583
|
+
const prov = this.activeProvider ?? "";
|
|
584
|
+
this.lastBulkMessage = `${verb} ${ids.length} in ${prov}`;
|
|
585
|
+
if (this.lastBulkTimer) { try { clearTimeout(this.lastBulkTimer); } catch {} }
|
|
586
|
+
this.lastBulkTimer = setTimeout(() => { this.lastBulkMessage = null; try { this.tui?.requestRender?.(); } catch {} }, 4000) as any;
|
|
587
|
+
if (this.cursorIndex >= this.rowsToShow.length) this.cursorIndex = Math.max(0, this.rowsToShow.length - 1);
|
|
588
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
589
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private rowsToShowForProvider(provider: string): ModelRow[] {
|
|
593
|
+
const base = this.rowsByProvider.get(provider) ?? [];
|
|
594
|
+
if (this.filteredRows !== null) {
|
|
595
|
+
const anyForProvider = this.filteredRows.some((r) => r.provider === provider);
|
|
596
|
+
if (anyForProvider) return this.filteredRows.filter((r) => r.provider === provider);
|
|
597
|
+
if (this.searchQuery.trim()) {
|
|
598
|
+
try {
|
|
599
|
+
return fuzzyFilter(this.searchQuery, base, (r) => `${r.provider}/${r.modelId} ${r.name ?? ""}`);
|
|
600
|
+
} catch { return []; }
|
|
601
|
+
}
|
|
602
|
+
return [];
|
|
603
|
+
}
|
|
604
|
+
return base;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private recomputeFilteredForActive(): void {
|
|
608
|
+
const q = this.searchQuery;
|
|
609
|
+
if (!q.trim()) {
|
|
610
|
+
this.filteredRows = null;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const base = this.activeRows;
|
|
614
|
+
try {
|
|
615
|
+
this.filteredRows = fuzzyFilter(q, base, (r) => `${r.provider}/${r.modelId} ${r.name ?? ""}`);
|
|
616
|
+
} catch {
|
|
617
|
+
this.filteredRows = null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private renderTabBar(width: number): string {
|
|
622
|
+
if (this.sortedProviders.length === 0) return this.safeFg("dim", " (no providers)");
|
|
623
|
+
const tabs: { styled: string; width: number }[] = [];
|
|
624
|
+
for (let i = 0; i < this.sortedProviders.length; i++) {
|
|
625
|
+
const p = this.sortedProviders[i];
|
|
626
|
+
const group = this.rowsByProvider.get(p) ?? [];
|
|
627
|
+
const vis = group.filter((r) => r.visible).length;
|
|
628
|
+
const total = group.length;
|
|
629
|
+
const count = `${vis}/${total}`;
|
|
630
|
+
const rawLabel = `${p} ${count}`;
|
|
631
|
+
let styled: string;
|
|
632
|
+
if (i === this.activeProviderIndex) {
|
|
633
|
+
const inner = ` [${rawLabel}] `;
|
|
634
|
+
let hl = inner;
|
|
635
|
+
try {
|
|
636
|
+
const bgged = this.safeBg("selection", inner);
|
|
637
|
+
if (bgged !== inner) hl = bgged;
|
|
638
|
+
else hl = this.safeFg("accent", this.safeBold(inner));
|
|
639
|
+
} catch {
|
|
640
|
+
hl = this.safeFg("accent", this.safeBold(inner));
|
|
641
|
+
}
|
|
642
|
+
styled = hl;
|
|
643
|
+
} else {
|
|
644
|
+
styled = this.safeFg("dim", ` [${rawLabel}] `);
|
|
645
|
+
}
|
|
646
|
+
const w = visibleWidthFallback(stripAnsi(styled));
|
|
647
|
+
tabs.push({ styled, width: w });
|
|
648
|
+
}
|
|
649
|
+
let totalW = tabs.reduce((s, t) => s + t.width, 0) + Math.max(0, tabs.length - 1);
|
|
650
|
+
if (totalW <= width) {
|
|
651
|
+
return tabs.map((t) => t.styled).join("");
|
|
652
|
+
}
|
|
653
|
+
let start = this.activeProviderIndex;
|
|
654
|
+
let end = this.activeProviderIndex + 1;
|
|
655
|
+
let curW = tabs[this.activeProviderIndex].width;
|
|
656
|
+
let left = this.activeProviderIndex - 1;
|
|
657
|
+
let right = this.activeProviderIndex + 1;
|
|
658
|
+
while (left >= 0 || right < tabs.length) {
|
|
659
|
+
let expanded = false;
|
|
660
|
+
if (left >= 0) {
|
|
661
|
+
const need = curW + 1 + tabs[left].width;
|
|
662
|
+
if (need <= width - 4) {
|
|
663
|
+
curW = need;
|
|
664
|
+
start = left;
|
|
665
|
+
left--;
|
|
666
|
+
expanded = true;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (right < tabs.length) {
|
|
670
|
+
const need = curW + 1 + tabs[right].width;
|
|
671
|
+
if (need <= width - 4) {
|
|
672
|
+
curW = need;
|
|
673
|
+
end = right + 1;
|
|
674
|
+
right++;
|
|
675
|
+
expanded = true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (!expanded) break;
|
|
679
|
+
}
|
|
680
|
+
const visible = tabs.slice(start, end);
|
|
681
|
+
let out = visible.map((t) => t.styled).join("");
|
|
682
|
+
if (start > 0) out = this.safeFg("dim", "‹ ") + out;
|
|
683
|
+
if (end < tabs.length) out = out + this.safeFg("dim", " ›");
|
|
684
|
+
return this.truncate(out, width);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private getAvailableHeight(): number {
|
|
688
|
+
let h: number | undefined;
|
|
689
|
+
try {
|
|
690
|
+
const term = (this.tui as any)?.terminal;
|
|
691
|
+
if (term) {
|
|
692
|
+
if (typeof term.rows === "number") h = term.rows;
|
|
693
|
+
else if (typeof term.height === "number") h = term.height;
|
|
694
|
+
else if (typeof term.getSize === "function") {
|
|
695
|
+
const sz = term.getSize();
|
|
696
|
+
if (sz && typeof sz.rows === "number") h = sz.rows;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch {}
|
|
700
|
+
if (h === undefined || h === null) {
|
|
701
|
+
try {
|
|
702
|
+
const c: any = (globalThis as any).process?.stdout;
|
|
703
|
+
if (c && typeof c.rows === "number") h = c.rows;
|
|
704
|
+
} catch {}
|
|
705
|
+
}
|
|
706
|
+
if (h === undefined || h === null || h <= 0) h = 24;
|
|
707
|
+
const searchExtra = this.searchActive ? 1 : (this.filteredRows !== null && this.searchQuery ? 1 : 0);
|
|
708
|
+
// Half-screen: reserve half the terminal height for the list
|
|
709
|
+
const half = Math.floor(h / 2);
|
|
710
|
+
// Account for chrome: title + 2 dividers + tab bar + blank spacer + status + 2-line footer
|
|
711
|
+
const chrome = 8 + searchExtra;
|
|
712
|
+
const avail = Math.max(5, half - chrome);
|
|
713
|
+
return Math.min(avail, 20);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private truncate(text: string, width: number): string {
|
|
717
|
+
try {
|
|
718
|
+
return truncateToWidthFn(text, width);
|
|
719
|
+
} catch {
|
|
720
|
+
return truncateFallback(text, width);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
render(width: number): string[] {
|
|
725
|
+
const lines: string[] = [];
|
|
726
|
+
const total = this.rows.length;
|
|
727
|
+
const visible = this.getVisibleCount();
|
|
728
|
+
const dirtyMark = this.isDirty() ? this.safeFg("warning", " ● unsaved") : "";
|
|
729
|
+
const titleText = this.safeFg("accent", this.safeBold("modelconf")) + this.safeFg("dim", ` ${total} models ${visible} visible`) + dirtyMark;
|
|
730
|
+
lines.push(this.truncate(titleText, width));
|
|
731
|
+
lines.push(this.truncate(this.safeFg("dim", "─".repeat(Math.max(0, width))), width));
|
|
732
|
+
lines.push(this.truncate(this.renderTabBar(width), width));
|
|
733
|
+
lines.push(this.truncate(this.safeFg("dim", "─".repeat(Math.max(0, width))), width));
|
|
734
|
+
if (this.searchActive) {
|
|
735
|
+
const inputWidth = Math.max(10, width - 4);
|
|
736
|
+
const rendered = this.searchInput.render(inputWidth);
|
|
737
|
+
const inputLine = rendered[0] ?? (this.searchInput as any).value ?? "";
|
|
738
|
+
const prefix = this.safeFg("accent", "/ ");
|
|
739
|
+
const line = prefix + inputLine;
|
|
740
|
+
lines.push(this.truncate(line, width));
|
|
741
|
+
} else if (this.filteredRows !== null && this.searchQuery) {
|
|
742
|
+
const hint = this.safeFg("dim", `filtered in ${this.activeProvider ?? ""}: "${this.searchQuery}" (${this.rowsToShow.length}/${this.activeRows.length}) • Esc to clear • / to search`);
|
|
743
|
+
lines.push(this.truncate(hint, width));
|
|
744
|
+
}
|
|
745
|
+
const rowsToShow = this.rowsToShow;
|
|
746
|
+
if (rowsToShow.length === 0) {
|
|
747
|
+
if (this.filteredRows !== null) {
|
|
748
|
+
const q = this.searchQuery;
|
|
749
|
+
const prov = this.activeProvider ?? "provider";
|
|
750
|
+
lines.push(this.truncate(this.safeFg("warning", ` No models match '${q}' in ${prov}`), width));
|
|
751
|
+
lines.push(this.truncate(this.safeFg("dim", " Press Esc to clear filter"), width));
|
|
752
|
+
} else if (this.sortedProviders.length === 0) {
|
|
753
|
+
lines.push(this.truncate(this.safeFg("dim", " No providers configured"), width));
|
|
754
|
+
} else {
|
|
755
|
+
lines.push(this.truncate(this.safeFg("dim", ` No models in ${this.activeProvider ?? "provider"}`), width));
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
const maxVisible = this.getAvailableHeight();
|
|
759
|
+
let start = 0;
|
|
760
|
+
let end = rowsToShow.length;
|
|
761
|
+
if (rowsToShow.length > maxVisible) {
|
|
762
|
+
const cursor = this.cursorIndex;
|
|
763
|
+
const half = Math.floor(maxVisible / 2);
|
|
764
|
+
start = Math.max(0, Math.min(cursor - half, rowsToShow.length - maxVisible));
|
|
765
|
+
end = Math.min(start + maxVisible, rowsToShow.length);
|
|
766
|
+
}
|
|
767
|
+
for (let idx = start; idx < end; idx++) {
|
|
768
|
+
const row = rowsToShow[idx];
|
|
769
|
+
const isCursor = this.cursorIndex === idx;
|
|
770
|
+
const mark = row.visible ? this.safeFg("success", "◉") : this.safeFg("dim", "○");
|
|
771
|
+
const labelPart = `${mark} ${row.modelId}`;
|
|
772
|
+
const dimName = row.name && row.name !== row.modelId ? this.safeFg("dim", ` ${row.name}`) : "";
|
|
773
|
+
let rawLine = `${labelPart}${dimName}`;
|
|
774
|
+
rawLine = ` ${rawLine}`;
|
|
775
|
+
let rendered: string;
|
|
776
|
+
if (isCursor) {
|
|
777
|
+
const highlighted = this.highlight(rawLine);
|
|
778
|
+
rendered = this.truncate(highlighted, width);
|
|
779
|
+
} else {
|
|
780
|
+
rendered = this.truncate(rawLine, width);
|
|
781
|
+
}
|
|
782
|
+
lines.push(rendered);
|
|
783
|
+
}
|
|
784
|
+
if (rowsToShow.length > maxVisible) {
|
|
785
|
+
const pos = this.cursorIndex >= 0 ? this.cursorIndex + 1 : 0;
|
|
786
|
+
const indicator = this.safeFg("dim", ` (${pos}/${rowsToShow.length}) in ${this.activeProvider ?? ""}`);
|
|
787
|
+
lines.push(this.truncate(indicator, width));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
lines.push("");
|
|
791
|
+
if (this.pendingDiscard) {
|
|
792
|
+
const msg = this.safeFg("warning", "Discard changes? (y/N)") + this.safeFg("dim", " y: discard n: keep editing Esc: discard");
|
|
793
|
+
lines.push(this.truncate(msg, width));
|
|
794
|
+
} else if (this.lastBulkMessage) {
|
|
795
|
+
const isInc = this.lastBulkMessage.startsWith("Included") || this.lastBulkMessage.startsWith("Enabled");
|
|
796
|
+
const bulk = this.safeFg(isInc ? "success" : "warning", this.lastBulkMessage) + this.safeFg("dim", this.isDirty() ? " • ● unsaved — Enter save" : "");
|
|
797
|
+
lines.push(this.truncate(bulk, width));
|
|
798
|
+
} else if (this.isDirty()) {
|
|
799
|
+
lines.push(this.truncate(this.safeFg("warning", "● unsaved — press Enter to save"), width));
|
|
800
|
+
} else if (this.searchActive) {
|
|
801
|
+
lines.push(this.truncate(this.safeFg("dim", "filtering — Esc clear • Enter keep filter"), width));
|
|
802
|
+
} else {
|
|
803
|
+
const countLine = this.safeFg("dim", `${visible} visible / ${total} total`);
|
|
804
|
+
lines.push(this.truncate(countLine, width));
|
|
805
|
+
}
|
|
806
|
+
// --- Persistent keybinds footer — always visible at bottom (all binds) ---
|
|
807
|
+
const kb1 = "Tab/⇧Tab ←→ switch provider │ ↑↓/j k Home/End navigate │ Space toggle │ / filter";
|
|
808
|
+
const kb2 = "a add visible │ x hide visible │ d toggle provider │ Enter save │ Esc/q close";
|
|
809
|
+
const sep = this.safeFg("dim", " │ ");
|
|
810
|
+
// If terminal is wide enough, show in one line; otherwise split into two
|
|
811
|
+
const oneLine = `${kb1} │ ${kb2}`;
|
|
812
|
+
let footerLines: string[];
|
|
813
|
+
try {
|
|
814
|
+
const w = visibleWidthFallback(stripAnsi(oneLine));
|
|
815
|
+
if (w + 2 <= width) {
|
|
816
|
+
footerLines = [this.safeFg("dim", oneLine)];
|
|
817
|
+
} else {
|
|
818
|
+
footerLines = [this.safeFg("dim", kb1), this.safeFg("dim", kb2)];
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
footerLines = [this.safeFg("dim", kb1), this.safeFg("dim", kb2)];
|
|
822
|
+
}
|
|
823
|
+
for (const fl of footerLines) lines.push(this.truncate(fl, width));
|
|
824
|
+
return lines.map((l) => this.truncate(l, width));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
handleInput(data: string): void {
|
|
828
|
+
if (!data) return;
|
|
829
|
+
const tryKb = (keyId: string): boolean => {
|
|
830
|
+
try {
|
|
831
|
+
if (this.keybindings) {
|
|
832
|
+
if (typeof this.keybindings.matches === "function" && this.keybindings.matches(data, keyId)) return true;
|
|
833
|
+
if (typeof this.keybindings.match === "function" && this.keybindings.match(data, keyId)) return true;
|
|
834
|
+
}
|
|
835
|
+
} catch {}
|
|
836
|
+
return false;
|
|
837
|
+
};
|
|
838
|
+
if (this.pendingDiscard) {
|
|
839
|
+
if (data === "y" || data === "Y") {
|
|
840
|
+
this.pendingDiscard = false;
|
|
841
|
+
this.onDone(false);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (data === "n" || data === "N") {
|
|
845
|
+
this.pendingDiscard = false;
|
|
846
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const isEscPend = data === "\x1b" || tryKb("escape") || data.includes("\x1b[27");
|
|
850
|
+
if (isEscPend) {
|
|
851
|
+
this.pendingDiscard = false;
|
|
852
|
+
this.onDone(false);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (!this.searchActive && data === "/") {
|
|
858
|
+
this.searchActive = true;
|
|
859
|
+
this.searchInput.focused = true;
|
|
860
|
+
this._focused = true;
|
|
861
|
+
if (typeof (this.searchInput as any).setValue === "function") (this.searchInput as any).setValue("");
|
|
862
|
+
else (this.searchInput as any).value = "";
|
|
863
|
+
this.searchQuery = "";
|
|
864
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
865
|
+
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
866
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (this.searchActive) {
|
|
870
|
+
// Arrow/Home/End navigate the filtered list while typing (left/right still edit the query).
|
|
871
|
+
const navUp = data === "\x1b[A" || data === "\x1bOA" || tryKb("up");
|
|
872
|
+
const navDown = data === "\x1b[B" || data === "\x1bOB" || tryKb("down");
|
|
873
|
+
const navHome = data === "\x1b[H" || data === "\x1b[1~" || data === "\x1bOH" || tryKb("home");
|
|
874
|
+
const navEnd = data === "\x1b[F" || data === "\x1b[4~" || data === "\x1bOF" || tryKb("end");
|
|
875
|
+
if (navUp || navDown || navHome || navEnd) {
|
|
876
|
+
const navList = this.rowsToShow;
|
|
877
|
+
if (navList.length > 0) {
|
|
878
|
+
if (navUp) this.cursorIndex = (this.cursorIndex - 1 + navList.length) % navList.length;
|
|
879
|
+
else if (navDown) this.cursorIndex = (this.cursorIndex + 1) % navList.length;
|
|
880
|
+
else if (navHome) this.cursorIndex = 0;
|
|
881
|
+
else this.cursorIndex = navList.length - 1;
|
|
882
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
883
|
+
}
|
|
884
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const isEscKitty = data.includes("\x1b[27");
|
|
888
|
+
const isEsc = data === "\x1b" || tryKb("escape") || isEscKitty;
|
|
889
|
+
const isEnter = data === "\r" || data === "\n" || data === "\r\n" || data.includes("\r") || data.includes("\n") || tryKb("enter") || tryKb("return");
|
|
890
|
+
if (data === "\t" || data === "\x1b[Z") {
|
|
891
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (isEsc) {
|
|
895
|
+
this.searchActive = false;
|
|
896
|
+
this.searchInput.focused = false;
|
|
897
|
+
this.filteredRows = null;
|
|
898
|
+
this.searchQuery = "";
|
|
899
|
+
if (typeof (this.searchInput as any).setValue === "function") (this.searchInput as any).setValue("");
|
|
900
|
+
else (this.searchInput as any).value = "";
|
|
901
|
+
const prov = this.activeProvider;
|
|
902
|
+
if (prov) {
|
|
903
|
+
const saved = this.perProviderCursor.get(prov);
|
|
904
|
+
const list = this.rowsToShow;
|
|
905
|
+
if (list.length === 0) this.cursorIndex = -1;
|
|
906
|
+
else this.cursorIndex = saved !== undefined ? Math.max(0, Math.min(saved, list.length - 1)) : 0;
|
|
907
|
+
} else {
|
|
908
|
+
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
909
|
+
}
|
|
910
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
911
|
+
return;
|
|
912
|
+
} else if (isEnter) {
|
|
913
|
+
this.searchActive = false;
|
|
914
|
+
this.searchInput.focused = false;
|
|
915
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
916
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
917
|
+
return;
|
|
918
|
+
} else {
|
|
919
|
+
try { (this.searchInput as any).handleInput?.(data); } catch {}
|
|
920
|
+
let q: string = "";
|
|
921
|
+
try {
|
|
922
|
+
const si: any = this.searchInput;
|
|
923
|
+
if (typeof si.getValue === "function") q = si.getValue();
|
|
924
|
+
else if (typeof si.value === "string") q = si.value;
|
|
925
|
+
else q = String(si.value ?? "");
|
|
926
|
+
} catch { q = (this.searchInput as any).value ?? ""; }
|
|
927
|
+
this.searchQuery = q;
|
|
928
|
+
if (!q.trim()) {
|
|
929
|
+
this.filteredRows = null;
|
|
930
|
+
const prov = this.activeProvider;
|
|
931
|
+
if (prov) {
|
|
932
|
+
const saved = this.perProviderCursor.get(prov);
|
|
933
|
+
this.cursorIndex = saved !== undefined ? Math.max(0, Math.min(saved, this.activeRows.length - 1)) : 0;
|
|
934
|
+
if (this.activeRows.length === 0) this.cursorIndex = -1;
|
|
935
|
+
} else {
|
|
936
|
+
this.cursorIndex = 0;
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
try {
|
|
940
|
+
const base = this.activeRows;
|
|
941
|
+
this.filteredRows = fuzzyFilter(q, base, (r) => `${r.provider}/${r.modelId} ${r.name ?? ""}`);
|
|
942
|
+
} catch {
|
|
943
|
+
this.filteredRows = null;
|
|
944
|
+
}
|
|
945
|
+
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
946
|
+
}
|
|
947
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (data === "\t" || data === "\x09") {
|
|
952
|
+
this.switchProvider(1);
|
|
953
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (data === "\x1b[Z") {
|
|
957
|
+
this.switchProvider(-1);
|
|
958
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (!this.searchActive && (data === "d" || data === "D")) {
|
|
962
|
+
const active = this.activeRows;
|
|
963
|
+
if (active.length === 0) return;
|
|
964
|
+
const vis = active.filter((r) => r.visible).length;
|
|
965
|
+
const allVisible = vis === active.length;
|
|
966
|
+
const ids = active.map((r) => `${r.provider}/${r.modelId}` as ModelId);
|
|
967
|
+
if (allVisible) {
|
|
968
|
+
this.draftEnabled = applyBulk(this.draftEnabled as any, this.allIds as unknown as ModelId[], ids, "exclude") as any;
|
|
969
|
+
this.lastBulkMessage = `Disabled all ${ids.length} in ${this.activeProvider ?? "provider"}`;
|
|
970
|
+
} else {
|
|
971
|
+
this.draftEnabled = applyBulk(this.draftEnabled as any, this.allIds as unknown as ModelId[], ids, "include") as any;
|
|
972
|
+
this.lastBulkMessage = `Enabled all ${ids.length} in ${this.activeProvider ?? "provider"}`;
|
|
973
|
+
}
|
|
974
|
+
this.rebuildVisibility();
|
|
975
|
+
if (this.lastBulkTimer) { try { clearTimeout(this.lastBulkTimer); } catch {} }
|
|
976
|
+
this.lastBulkTimer = setTimeout(() => { this.lastBulkMessage = null; try { this.tui?.requestRender?.(); } catch {} }, 4000) as any;
|
|
977
|
+
if (this.cursorIndex >= this.rowsToShow.length) this.cursorIndex = Math.max(0, this.rowsToShow.length - 1);
|
|
978
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
979
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (!this.searchActive && (data === "a" || data === "A")) {
|
|
983
|
+
this.applyVisibleBulk("include");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (!this.searchActive && (data === "x" || data === "X")) {
|
|
987
|
+
this.applyVisibleBulk("exclude");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const isEscKitty = data.includes("\x1b[27");
|
|
991
|
+
const isEsc = data === "\x1b" || tryKb("escape") || isEscKitty;
|
|
992
|
+
const isQ = data === "q" || data === "Q";
|
|
993
|
+
if ((isEsc || isQ) && this.filteredRows !== null) {
|
|
994
|
+
this.filteredRows = null;
|
|
995
|
+
this.searchQuery = "";
|
|
996
|
+
if (typeof (this.searchInput as any).setValue === "function") (this.searchInput as any).setValue("");
|
|
997
|
+
else (this.searchInput as any).value = "";
|
|
998
|
+
this.searchInput.focused = false;
|
|
999
|
+
this.searchActive = false;
|
|
1000
|
+
const prov = this.activeProvider;
|
|
1001
|
+
if (prov) {
|
|
1002
|
+
const saved = this.perProviderCursor.get(prov);
|
|
1003
|
+
const list = this.activeRows;
|
|
1004
|
+
if (list.length === 0) this.cursorIndex = -1;
|
|
1005
|
+
else this.cursorIndex = saved !== undefined ? Math.max(0, Math.min(saved, list.length - 1)) : 0;
|
|
1006
|
+
} else {
|
|
1007
|
+
this.cursorIndex = this.rowsToShow.length > 0 ? 0 : -1;
|
|
1008
|
+
}
|
|
1009
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (isEsc || isQ) {
|
|
1013
|
+
if (this.isDirty()) {
|
|
1014
|
+
this.pendingDiscard = true;
|
|
1015
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
this.onDone(false);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (data === " ") {
|
|
1022
|
+
const row = this.cursorRow();
|
|
1023
|
+
if (!row) return;
|
|
1024
|
+
const modelId = `${row.provider}/${row.modelId}` as ModelId;
|
|
1025
|
+
// Hide/show operates on the full model list (this.allIds is the full registry),
|
|
1026
|
+
// even though the view is scoped to configured providers. This ensures toggling
|
|
1027
|
+
// a model affects the main full enabledModels list, not just the scoped subset.
|
|
1028
|
+
this.draftEnabled = toggleInEnabled(modelId, this.draftEnabled, this.allIds as ModelId[]);
|
|
1029
|
+
this.rebuildVisibility();
|
|
1030
|
+
this.pendingDiscard = false;
|
|
1031
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1032
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const isEnter = data === "\r" || data === "\n" || data === "\r\n" || tryKb("enter") || tryKb("return");
|
|
1036
|
+
const isEnterRaw = isEnter || data.includes("\r") || data.includes("\n");
|
|
1037
|
+
if (isEnterRaw) {
|
|
1038
|
+
const toSave = this.draftEnabled;
|
|
1039
|
+
Promise.resolve(this._onPersist(toSave as any)).then(() => {
|
|
1040
|
+
this.onDone(true);
|
|
1041
|
+
}).catch(() => {
|
|
1042
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1043
|
+
});
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
// Left/Right also switch provider (alternative to Tab/Shift-Tab) when not in search
|
|
1047
|
+
const isLeft = data === "\x1b[D" || data === "\x1bOD" || tryKb("left");
|
|
1048
|
+
const isRight = data === "\x1b[C" || data === "\x1bOC" || tryKb("right");
|
|
1049
|
+
if (isLeft) {
|
|
1050
|
+
this.switchProvider(-1);
|
|
1051
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (isRight) {
|
|
1055
|
+
this.switchProvider(1);
|
|
1056
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
let handled = false;
|
|
1060
|
+
const isUp = data === "k" || data === "\x1b[A" || data === "\x1bOA" || tryKb("up") || tryKb("k");
|
|
1061
|
+
const isDown = data === "j" || data === "\x1b[B" || data === "\x1bOB" || tryKb("down") || tryKb("j");
|
|
1062
|
+
const isHome = data === "g" || data === "\x1b[H" || data === "\x1b[1~" || data === "\x1bOH" || tryKb("home") || tryKb("g");
|
|
1063
|
+
const isEnd = data === "G" || data === "\x1b[F" || data === "\x1b[4~" || data === "\x1bOF" || tryKb("end") || tryKb("G");
|
|
1064
|
+
const navList = this.rowsToShow;
|
|
1065
|
+
if (isUp) {
|
|
1066
|
+
if (navList.length > 0) {
|
|
1067
|
+
const next = (this.cursorIndex - 1 + navList.length) % navList.length;
|
|
1068
|
+
this.cursorIndex = next;
|
|
1069
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1070
|
+
handled = true;
|
|
1071
|
+
}
|
|
1072
|
+
} else if (isDown) {
|
|
1073
|
+
if (navList.length > 0) {
|
|
1074
|
+
const next = (this.cursorIndex + 1) % navList.length;
|
|
1075
|
+
this.cursorIndex = next;
|
|
1076
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1077
|
+
handled = true;
|
|
1078
|
+
}
|
|
1079
|
+
} else if (isHome) {
|
|
1080
|
+
if (navList.length > 0) {
|
|
1081
|
+
this.cursorIndex = 0;
|
|
1082
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1083
|
+
handled = true;
|
|
1084
|
+
}
|
|
1085
|
+
} else if (isEnd) {
|
|
1086
|
+
if (navList.length > 0) {
|
|
1087
|
+
this.cursorIndex = navList.length - 1;
|
|
1088
|
+
if (this.activeProvider) this.perProviderCursor.set(this.activeProvider, this.cursorIndex);
|
|
1089
|
+
handled = true;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (handled) {
|
|
1093
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
invalidate(): void {
|
|
1098
|
+
try { this.tui?.requestRender?.(); } catch {}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|