pi-soly 2.5.0 → 2.5.2

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/mcp/mcp-panel.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
3
+ import { fuzzyScore } from "../visual/fuzzy.ts";
4
+ import { borderTop, borderBottom, borderDivider, borderRow, borderEmpty, type BorderStyler } from "../visual/border.ts";
3
5
  import { isToolExcluded } from "./types.ts";
4
6
  import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
5
7
  import { resourceNameToToolName } from "./resource-tools.ts";
@@ -55,24 +57,7 @@ function rainbowProgress(filled: number, total: number): string {
55
57
  return dots.join(" ");
56
58
  }
57
59
 
58
- function fuzzyScore(query: string, text: string): number {
59
- const lq = query.toLowerCase();
60
- const lt = text.toLowerCase();
61
- if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
62
- let score = 0;
63
- let qi = 0;
64
- let consecutive = 0;
65
- for (let i = 0; i < lt.length && qi < lq.length; i++) {
66
- if (lt[i] === lq[qi]) {
67
- score += 10 + consecutive;
68
- consecutive += 5;
69
- qi++;
70
- } else {
71
- consecutive = 0;
72
- }
73
- }
74
- return qi === lq.length ? score : 0;
75
- }
60
+ // fuzzyScore moved to ../visual/fuzzy.ts imported above.
76
61
 
77
62
  function estimateTokens(tool: CachedTool): number {
78
63
  const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
@@ -599,18 +584,15 @@ class McpPanel {
599
584
  const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
600
585
  const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
601
586
 
602
- const row = (content: string) =>
603
- fg(t.border, "│") + truncateToWidth(" " + content, innerW, "…", true) + fg(t.border, "│");
604
- const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│");
605
- const divider = () => fg(t.border, "├" + "─".repeat(innerW) + "┤");
587
+ // Shared border styler colors border chars via the panel theme.
588
+ const borderS: BorderStyler = (s: string) => fg(t.border, s);
589
+ const titleS: BorderStyler = (s: string) => fg(t.title, s);
590
+ const row = (content: string) => borderRow(content, innerW, borderS);
591
+ const emptyRow = () => borderEmpty(innerW, borderS);
592
+ const divider = () => borderDivider(innerW, borderS);
606
593
 
607
- const titleText = this.authOnly ? " MCP OAuth " : " MCP Servers ";
608
- const borderLen = innerW - visibleWidth(titleText);
609
- const leftB = Math.floor(borderLen / 2);
610
- const rightB = borderLen - leftB;
611
- lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
612
-
613
- lines.push(emptyRow());
594
+ const titleText = this.authOnly ? "MCP OAuth" : "MCP Servers";
595
+ lines.push(borderTop(titleText, innerW, borderS, titleS));
614
596
 
615
597
  const cursor = fg(t.selected, "│");
616
598
  const searchIcon = fg(t.border, "◎");
@@ -622,27 +604,21 @@ class McpPanel {
622
604
  lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
623
605
  }
624
606
 
625
- lines.push(emptyRow());
626
607
  if (this.noticeLines.length > 0) {
627
608
  for (const notice of this.noticeLines) {
628
609
  lines.push(row(fg(t.hint, italic(notice))));
629
610
  }
630
- lines.push(emptyRow());
631
611
  }
632
612
  lines.push(divider());
633
613
 
634
614
  if (this.servers.length === 0) {
635
- lines.push(emptyRow());
636
615
  lines.push(row(fg(t.hint, italic(this.authOnly ? "No OAuth-capable MCP servers configured." : "No MCP servers configured."))));
637
- lines.push(emptyRow());
638
616
  } else {
639
617
  const maxVis = McpPanel.MAX_VISIBLE;
640
618
  const total = this.visibleItems.length;
641
619
  const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
642
620
  const endIdx = Math.min(startIdx + maxVis, total);
643
621
 
644
- lines.push(emptyRow());
645
-
646
622
  for (let i = startIdx; i < endIdx; i++) {
647
623
  const item = this.visibleItems[i];
648
624
  const isCursor = i === this.cursorIndex;
@@ -655,26 +631,20 @@ class McpPanel {
655
631
  }
656
632
  }
657
633
 
658
- lines.push(emptyRow());
659
-
660
634
  if (total > maxVis) {
661
635
  const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
662
636
  lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
663
- lines.push(emptyRow());
664
637
  }
665
638
 
666
639
  if (this.importNotice) {
667
640
  lines.push(row(fg(t.needsAuth, italic(this.importNotice))));
668
- lines.push(emptyRow());
669
641
  }
670
642
  if (this.authNotice) {
671
643
  lines.push(row(fg(t.needsAuth, italic(this.authNotice))));
672
- lines.push(emptyRow());
673
644
  }
674
645
  }
675
646
 
676
647
  lines.push(divider());
677
- lines.push(emptyRow());
678
648
 
679
649
  if (this.confirmingDiscard) {
680
650
  const discardBtn = this.discardSelected === 0
@@ -699,7 +669,6 @@ class McpPanel {
699
669
  }
700
670
  }
701
671
 
702
- lines.push(emptyRow());
703
672
  const hints = this.authOnly
704
673
  ? [
705
674
  italic("↑↓") + " navigate",
@@ -738,8 +707,7 @@ class McpPanel {
738
707
  }
739
708
  if (curLine) lines.push(row(fg(t.hint, curLine)));
740
709
 
741
- lines.push(fg(t.border, "╰" + "─".repeat(innerW) + "╯"));
742
-
710
+ lines.push(borderBottom(innerW, borderS));
743
711
  return lines;
744
712
  }
745
713
 
@@ -1,5 +1,6 @@
1
1
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../visual/panel-keys.ts";
3
+ import { borderTop, borderBottom, borderDivider, borderRow, borderEmpty, type BorderStyler } from "../visual/border.ts";
3
4
  import type { ImportKind } from "./types.ts";
4
5
  import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
5
6
  import type { McpOnboardingState } from "./onboarding-state.ts";
@@ -345,22 +346,24 @@ export class McpSetupPanel {
345
346
  render(width: number): string[] {
346
347
  const innerW = Math.max(40, width - 2);
347
348
  const lines: string[] = [];
348
- const border = fg(this.t.border, "─".repeat(innerW));
349
- lines.push(`┌${border}┐`);
350
- lines.push(this.padLine(fg(this.t.title, "MCP setup"), innerW));
351
- lines.push(this.padLine(this.discoverySummaryLine(), innerW));
352
- lines.push(this.padLine(fg(this.t.muted, this.secondarySummaryLine()), innerW));
353
- lines.push(this.padLine("", innerW));
349
+ const borderS: BorderStyler = (s: string) => fg(this.t.border, s);
350
+ const row = (content: string) => borderRow(content, innerW, borderS);
351
+
352
+ lines.push(borderTop("", innerW, borderS));
353
+ lines.push(row(fg(this.t.title, "MCP setup")));
354
+ lines.push(row(this.discoverySummaryLine()));
355
+ lines.push(row(fg(this.t.muted, this.secondarySummaryLine())));
356
+ lines.push(borderEmpty(innerW, borderS));
354
357
 
355
358
  if (this.notice) {
356
359
  const tone = this.notice.tone === "success" ? this.t.success : this.notice.tone === "warning" ? this.t.warning : this.t.hint;
357
360
  for (const line of wrapText(this.notice.text, innerW - 6)) {
358
- lines.push(this.padLine(fg(tone, line), innerW));
361
+ lines.push(row(fg(tone, line)));
359
362
  }
360
- lines.push(this.padLine("", innerW));
363
+ lines.push(borderEmpty(innerW, borderS));
361
364
  }
362
365
 
363
- lines.push(`├${border}┤`);
366
+ lines.push(borderDivider(innerW, borderS));
364
367
 
365
368
  if (this.screen === "imports") {
366
369
  lines.push(...this.renderImports(innerW));
@@ -370,7 +373,7 @@ export class McpSetupPanel {
370
373
  lines.push(...this.renderActions(innerW));
371
374
  }
372
375
 
373
- lines.push(`└${border}┘`);
376
+ lines.push(borderBottom(innerW, borderS));
374
377
  return lines;
375
378
  }
376
379
 
@@ -554,12 +557,7 @@ export class McpSetupPanel {
554
557
  }
555
558
 
556
559
  private padLine(text: string, innerW: number): string {
557
- const inset = 2;
558
- const contentW = Math.max(0, innerW - inset * 2);
559
- const fitted = truncateToWidth(text, contentW, "…", true);
560
- const plainWidth = visibleWidth(fitted);
561
- const padding = Math.max(0, contentW - plainWidth);
562
- return `│${" ".repeat(inset)}${fitted}${" ".repeat(padding)}${" ".repeat(inset)}│`;
560
+ return borderRow(text, innerW, (s) => fg(this.t.border, s));
563
561
  }
564
562
 
565
563
  invalidate(): void {}
package/nudge.ts CHANGED
@@ -28,6 +28,41 @@ const NON_TRIVIAL_VERBS =
28
28
  const RESEARCH_VERBS =
29
29
  /\b(find out|look up|check|verify|investigate|research|figure out|figure out how|discover|why does|how does|what is the best|compare|which library|which approach|benchmark|audit|review|trace|debug why)\b/i;
30
30
 
31
+ // Complaint patterns — the user is unhappy about code the agent wrote or a
32
+ // behavior it exhibits. Triggers the agent-coach skill (proposes a soly rule
33
+ // to prevent recurrence). RU + EN, case-insensitive. Word-boundary where
34
+ // possible; some RU forms don't use \b well so we anchor on the phrase.
35
+ const COMPLAINT_PATTERNS: RegExp[] = [
36
+ // Russian — explicit dissatisfaction
37
+ /меня напрягает/i,
38
+ /мне не нравится/i,
39
+ /не нравится/i,
40
+ /переделай/i,
41
+ /почему опять/i,
42
+ /опять\s+(?:эт[оа]|этот|эта)/i,
43
+ /не делай так/i,
44
+ /убирай?/i,
45
+ /кринж/i,
46
+ /бесит/i,
47
+ /заставь/i,
48
+ /хватит (?:делать|писать|так)/i,
49
+ /опять (?:делаешь|пишешь|создаёшь|создаешь)/i,
50
+ // English
51
+ /redo .*(?:differently|another way|other way)/i,
52
+ /i don't like/i,
53
+ /why (?:does|do) .*(?:keep|always)/i,
54
+ /stop doing/i,
55
+ /don't do that/i,
56
+ /annoying/i,
57
+ /make .* not/i,
58
+ ];
59
+
60
+ /** Detect whether the user prompt is a complaint about agent behavior/coding.
61
+ * Used to inject the agent-coach skill directive so the LLM doesn't skip it. */
62
+ export function detectComplaint(prompt: string): boolean {
63
+ return COMPLAINT_PATTERNS.some((rx) => rx.test(prompt));
64
+ }
65
+
31
66
  const URL_PATTERN = /https?:\/\/\S+/;
32
67
  // Library/version-ish reference (e.g. v1.2.3, @scope/pkg).
33
68
  // NB: no leading `\b` — `@` is a non-word char so `\b` won't match before it.
@@ -37,6 +72,9 @@ const VERSION_PATTERN = /(?<!\w)(v?\d+\.\d+(?:\.\d+)?|@[\w\-]+\/[\w\-]+)(?!\w)/;
37
72
  export interface TaskHeuristics {
38
73
  nonTrivial: boolean;
39
74
  researchHeavy: boolean;
75
+ /** True when the prompt reads as a complaint about agent-written code or
76
+ * behavior — triggers the agent-coach skill directive. */
77
+ complaint: boolean;
40
78
  mentions: string[];
41
79
  suggestedAngles: string[];
42
80
  }
@@ -48,6 +86,7 @@ export function classifyTaskHeuristics(prompt: string): TaskHeuristics {
48
86
  const hasResearch = RESEARCH_VERBS.test(trimmed);
49
87
  const hasUrl = URL_PATTERN.test(trimmed);
50
88
  const hasVersion = VERSION_PATTERN.test(trimmed);
89
+ const hasComplaint = detectComplaint(trimmed);
51
90
 
52
91
  // Extract file-ish mentions from the prompt. We don't import core's
53
92
  // extractFilePathsFromPrompt to keep nudge.ts self-contained.
@@ -85,7 +124,7 @@ export function classifyTaskHeuristics(prompt: string): TaskHeuristics {
85
124
  suggestedAngles.push("any constraints (deadline, scope, style) I should know?");
86
125
  }
87
126
 
88
- return { nonTrivial, researchHeavy, mentions, suggestedAngles };
127
+ return { nonTrivial, researchHeavy, complaint: hasComplaint, mentions, suggestedAngles };
89
128
  }
90
129
 
91
130
  // ---------------------------------------------------------------------------
@@ -140,6 +179,9 @@ export function buildNudgeSection(
140
179
  if (heuristics.researchHeavy) {
141
180
  triggers.push("research-heavy (web lookup / library decision / unknown behavior)");
142
181
  }
182
+ if (heuristics.complaint) {
183
+ triggers.push("user complaint about code/behavior (agent-coach eligible)");
184
+ }
143
185
 
144
186
  const triggerLine = triggers.length
145
187
  ? `Heuristics for this prompt: ${triggers.join("; ")}.`
@@ -213,6 +255,17 @@ export function buildNudgeSection(
213
255
  ? `\n\n ${confirmLevel === "scope" ? SCOPE_DIRECTIVE : ASK_DIRECTIVE}`
214
256
  : "";
215
257
 
258
+ // Complaint directive: when the user is unhappy about code the agent wrote
259
+ // or a behavior it exhibits, inject an explicit order to load the
260
+ // agent-coach skill and follow its workflow. Passive skills (listed in
261
+ // available_skills) are unreliable — the LLM often skips them and jumps
262
+ // into the code. This directive lands in the system prompt right before
263
+ // the turn, so it can't be missed.
264
+ const complaintBlock =
265
+ heuristics.complaint
266
+ ? `\n\n5. **⚠️ User complaint detected — use the agent-coach skill.** The user is unhappy about code behavior. DO NOT just fix this instance and move on. First read the **agent-coach** skill file (\`packages/pi-soly/skills/agent-coach/SKILL.md\`) and follow its workflow: analyze the complaint → extract the underlying pattern → check existing rules (dedup) → draft a soly rule (\`.agents/rules/[category]/[name].md\`) → propose via \`ask_pro\` → write only on confirmation. This closes the feedback loop so the mistake doesn't repeat. If a rule already covers it but was ignored, the rule is too vague — propose strengthening it instead of creating a duplicate.`
267
+ : "";
268
+
216
269
  return `
217
270
 
218
271
  ## soly behavioral nudge (always on)
@@ -224,7 +277,7 @@ The following are user-set defaults, not project rules. They tell you how the us
224
277
 
225
278
  2. **Scout with soly's own read tools.** When you need to read unfamiliar code, map a directory, or gather context, use soly's read tools — \`soly_snippet(path, offset, limit)\`, \`soly_doc_search(query)\`, \`soly_read(...)\` — plus \`grep\` / \`find\`. Prefer bounded snippets over reading whole files. soly does the work INLINE in this session; there is no separate worker or \`subagent(...)\` tool to delegate to (and none is required).
226
279
 
227
- 3. **Reach for soly's interaction tools.** For structured questions use \`ask_pro\` (batched, multi-select, ⭐ recommended); for design/architecture forks where the choice hinges on the concrete code shape use \`decision_deck\`; for visual output (galleries, comparisons, diagrams) use \`html_artifact\`. Give each question a concrete recommended default + rationale — don't dump open-ended prompts.${workflowPoint}
280
+ 3. **Reach for soly's interaction tools.** For structured questions use \`ask_pro\` (batched, multi-select, ⭐ recommended); for design/architecture forks where the choice hinges on the concrete code shape use \`decision_deck\`; for visual output (galleries, comparisons, diagrams) use \`html_artifact\`. Give each question a concrete recommended default + rationale — don't dump open-ended prompts.${workflowPoint}${complaintBlock}
228
281
 
229
282
  Treat (1) and (2) as defaults, not laws. The user can always override per-task ("just do it", "ask me everything"). When overriding, briefly acknowledge it.
230
283
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-coach
3
- description: Use when the user complains about how the agent wrote code or behaves — phrases like "переделай X по-другому", "мне не нравится что agent делает Y", "почему опять Z", "не делай так", "agent keeps doing X", "I don't like the agent doing X", "заставь agent-а не делать X", "опять этот god switch". Analyzes the complaint, identifies the missing rule that would have prevented it, and proposes a soly rule draft (.agents/rules/*.md) for the user to confirm via ask_pro. Language-agnostic — works for any codebase. NOT for C#/.NET analyzer rules (use analyzer-coach for that).
3
+ description: Use when the user complains about how the agent wrote code or behaves — phrases like "переделай X по-другому", "мне не нравится что agent делает Y", "почему опять Z", "не делай так", "меня напрягает", "кринж", "убирай", "бесит", "agent keeps doing X", "I don't like the agent doing X", "заставь agent-а не делать X", "опять этот god switch", "redo differently", "annoying". Analyzes the complaint, identifies the missing rule that would have prevented it, and proposes a soly rule draft (.agents/rules/*.md) for the user to confirm via ask_pro. Language-agnostic — works for any codebase. NOT for C#/.NET analyzer rules (use analyzer-coach for that).
4
4
  priority: high
5
5
  ---
6
6
 
@@ -0,0 +1,64 @@
1
+ // =============================================================================
2
+ // visual/border.ts — shared rounded-border renderer for modal panels
3
+ // =============================================================================
4
+ //
5
+ // All soly modal panels (ListPanel, McpPanel, McpSetupPanel) draw bordered
6
+ // boxes. Before this module, each had its own helpers:
7
+ // - list-panel: frame(), headerLine(), footerLine(), ruleLine() (┌┐└┘)
8
+ // - mcp-panel: row(), emptyRow(), divider() (╭╮╰╯ + raw ANSI)
9
+ // - mcp-setup: padLine() (┌┐└┘)
10
+ //
11
+ // This module unifies them under one API with rounded corners (╭╮╰╯├┤│─).
12
+ // Each function takes a `styler` ((s: string) => string) so it works with
13
+ // any color system — pi's Theme.fg(), mcp-panel's fg(), or identity in tests.
14
+ //
15
+ // All width math uses pi-tui's visibleWidth/truncateToWidth so ANSI color
16
+ // codes inside `content` never break alignment (same approach as list-panel).
17
+ // =============================================================================
18
+
19
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
+
21
+ /** Colors the border characters. Identity (s => s) in tests. */
22
+ export type BorderStyler = (s: string) => string;
23
+
24
+ /** Top border with a centered title: `╭── title ──╮`. */
25
+ export function borderTop(title: string, innerW: number, styler: BorderStyler, titleStyler?: BorderStyler): string {
26
+ const ts = titleStyler ?? styler;
27
+ const titleW = visibleWidth(title);
28
+ const dashTotal = Math.max(0, innerW - titleW);
29
+ const leftDash = Math.floor(dashTotal / 2);
30
+ const rightDash = dashTotal - leftDash;
31
+ return styler("╭" + "─".repeat(leftDash)) + ts(title) + styler("─".repeat(rightDash) + "╮");
32
+ }
33
+
34
+ /** Bottom border: `╰──...──╯`. */
35
+ export function borderBottom(innerW: number, styler: BorderStyler): string {
36
+ return styler("╰" + "─".repeat(innerW) + "╯");
37
+ }
38
+
39
+ /** Mid-panel divider: `├──...──┤`. */
40
+ export function borderDivider(innerW: number, styler: BorderStyler): string {
41
+ return styler("├" + "─".repeat(innerW) + "┤");
42
+ }
43
+
44
+ /** Labelled divider inside the panel: `├── label ──┤`. */
45
+ export function borderLabelledDivider(label: string, innerW: number, styler: BorderStyler, labelStyler?: BorderStyler): string {
46
+ const ls = labelStyler ?? styler;
47
+ const head = `─ ${label} `;
48
+ const dashN = Math.max(0, innerW - visibleWidth(head) - 1);
49
+ return styler("├ ") + ls(head) + styler("─".repeat(dashN) + "┤");
50
+ }
51
+
52
+ /** Content row: `│ content │` (truncated with `…` if too wide, padded if short). */
53
+ export function borderRow(content: string, innerW: number, styler: BorderStyler): string {
54
+ const truncated = visibleWidth(content) > innerW
55
+ ? truncateToWidth(content, innerW, "…", true)
56
+ : content;
57
+ const pad = Math.max(0, innerW - visibleWidth(truncated));
58
+ return styler("│ ") + truncated + " ".repeat(pad) + styler(" │");
59
+ }
60
+
61
+ /** Empty row: `│ │`. */
62
+ export function borderEmpty(innerW: number, styler: BorderStyler): string {
63
+ return styler("│") + " ".repeat(innerW) + styler("│");
64
+ }
@@ -0,0 +1,26 @@
1
+ // =============================================================================
2
+ // visual/fuzzy.ts — shared subsequence fuzzy match scorer
3
+ // =============================================================================
4
+ //
5
+ // Extracted from list-panel.ts (where it was duplicated in mcp-panel.ts).
6
+ // Pure function, no deps — trivially testable.
7
+ //
8
+ // Scoring:
9
+ // - exact substring match: 100 + (queryLen / textLen) — highest
10
+ // - in-order subsequence: 1 — match but not contiguous
11
+ // - no match: 0
12
+ // =============================================================================
13
+
14
+ /** Subsequence fuzzy match: substring scores highest, then in-order chars.
15
+ * Returns 0 for no match, >0 for a match. */
16
+ export function fuzzyScore(query: string, text: string): number {
17
+ const q = query.toLowerCase();
18
+ const t = text.toLowerCase();
19
+ if (!q) return 1;
20
+ if (t.includes(q)) return 100 + q.length / t.length;
21
+ let qi = 0;
22
+ for (let i = 0; i < t.length && qi < q.length; i++) {
23
+ if (t[i] === q[qi]) qi++;
24
+ }
25
+ return qi === q.length ? 1 : 0;
26
+ }
@@ -16,6 +16,7 @@ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@ea
16
16
  import type { Component, TUI } from "@earendil-works/pi-tui";
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
18
  import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
19
+ import { fuzzyScore } from "./fuzzy.ts";
19
20
  // (PanelKeybindings re-exported by re-export side below.)
20
21
 
21
22
  /** One row in the panel. `body` is shown in the preview pane when selected. */
@@ -60,15 +61,7 @@ const MAX_ROWS = 12; // list window height (render() has no viewport height)
60
61
  const PREVIEW_LINES = 4;
61
62
 
62
63
  /** Subsequence fuzzy match: substring scores highest, then in-order chars. */
63
- function fuzzyScore(query: string, text: string): number {
64
- const q = query.toLowerCase();
65
- const t = text.toLowerCase();
66
- if (!q) return 1;
67
- if (t.includes(q)) return 100 + q.length / t.length;
68
- let qi = 0;
69
- for (let i = 0; i < t.length && qi < q.length; i++) if (t[i] === q[qi]) qi++;
70
- return qi === q.length ? 1 : 0;
71
- }
64
+ // fuzzyScore moved to ./fuzzy.ts imported above.
72
65
 
73
66
  /** Index into `flatRows` (mixed item + group header rows). */
74
67
  type RowIndex = number;
@@ -253,7 +246,7 @@ export class ListPanel implements Component {
253
246
  const left = ` ${this.p.title} `;
254
247
  const right = this.p.headerRight ? ` ${this.p.headerRight} ` : "";
255
248
  const fillN = Math.max(1, inner + 2 - visibleWidth(left) - visibleWidth(right));
256
- return dim("") + muted(left) + dim("─".repeat(fillN)) + muted(right) + dim("");
249
+ return dim("") + muted(left) + dim("─".repeat(fillN)) + muted(right) + dim("");
257
250
  }
258
251
 
259
252
  private searchLine(inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
@@ -289,6 +282,6 @@ export class ListPanel implements Component {
289
282
  const open = this.p.onSelect ? "⏎ open · " : "";
290
283
  const hint = ` ↑↓ move · ${open}/ search${acts ? " · " + acts : ""} · esc `;
291
284
  const fillN = Math.max(1, inner + 2 - visibleWidth(hint));
292
- return dim("") + dim(hint) + dim("─".repeat(fillN)) + dim("");
285
+ return dim("") + dim(hint) + dim("─".repeat(fillN)) + dim("");
293
286
  }
294
287
  }