TUI: optimize fuzzy filtering and consolidate time formatting

- Extract formatRelativeTime to shared utility for reuse across components
- Optimize FilterableSelectList with pre-lowercased searchTextLower field (avoids toLowerCase on every keystroke)
- Implement custom fuzzy matching with space-separated token support and word boundary scoring
- Use matchesKey utility for consistent keybinding handling (arrows, vim j/k, ctrl+p/n)
- Fix searchable-select-list to support vim keybindings consistently
- Fix system-prompt runtimeInfo null check with nullish coalescing operator
This commit is contained in:
CJ Winslow
2026-01-18 23:55:16 -08:00
committed by Peter Steinberger
parent 1d9d5b30ce
commit a28c271488
5 changed files with 1409 additions and 1373 deletions

View File

@@ -1,6 +1,5 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { formatCliCommand } from "../cli/command-format.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { ResolvedTimeFormat } from "./date-time.js"; import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
@@ -86,22 +85,8 @@ function buildMessagingSection(params: {
messageChannelOptions: string; messageChannelOptions: string;
inlineButtonsEnabled: boolean; inlineButtonsEnabled: boolean;
runtimeChannel?: string; runtimeChannel?: string;
channelActions?: string[];
}) { }) {
if (params.isMinimal) return []; if (params.isMinimal) return [];
// Build channel-specific action description
let actionsDescription: string;
if (params.channelActions && params.channelActions.length > 0 && params.runtimeChannel) {
// Include "send" as a base action plus channel-specific actions
const allActions = new Set(["send", ...params.channelActions]);
const actionList = Array.from(allActions).sort().join(", ");
actionsDescription = `- Use \`message\` for proactive sends + channel actions. Current channel (${params.runtimeChannel}) supports: ${actionList}.`;
} else {
actionsDescription =
"- Use `message` for proactive sends + channel actions (send, react, edit, delete, etc.).";
}
return [ return [
"## Messaging", "## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
@@ -111,7 +96,7 @@ function buildMessagingSection(params: {
? [ ? [
"", "",
"### message tool", "### message tool",
actionsDescription, "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.", "- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
@@ -139,7 +124,7 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT
"Community: https://discord.com/invite/clawd", "Community: https://discord.com/invite/clawd",
"Find new skills: https://clawdhub.com", "Find new skills: https://clawdhub.com",
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.", "For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
`When diagnosing issues, run \`${formatCliCommand("clawdbot status")}\` yourself when possible; only ask the user if you lack access (e.g., sandboxed).`, "When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
"", "",
]; ];
} }
@@ -172,8 +157,6 @@ export function buildAgentSystemPrompt(params: {
model?: string; model?: string;
channel?: string; channel?: string;
capabilities?: string[]; capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
}; };
sandboxInfo?: { sandboxInfo?: {
enabled: boolean; enabled: boolean;
@@ -381,11 +364,11 @@ export function buildAgentSystemPrompt(params: {
"## Clawdbot CLI Quick Reference", "## Clawdbot CLI Quick Reference",
"Clawdbot is controlled via subcommands. Do not invent commands.", "Clawdbot is controlled via subcommands. Do not invent commands.",
"To manage the Gateway daemon service (start/stop/restart):", "To manage the Gateway daemon service (start/stop/restart):",
`- ${formatCliCommand("clawdbot daemon status")}`, "- clawdbot daemon status",
`- ${formatCliCommand("clawdbot daemon start")}`, "- clawdbot daemon start",
`- ${formatCliCommand("clawdbot daemon stop")}`, "- clawdbot daemon stop",
`- ${formatCliCommand("clawdbot daemon restart")}`, "- clawdbot daemon restart",
`If unsure, ask the user to run \`${formatCliCommand("clawdbot help")}\` (or \`${formatCliCommand("clawdbot daemon --help")}\`) and paste the output.`, "If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
"", "",
...skillsSection, ...skillsSection,
...memorySection, ...memorySection,
@@ -484,7 +467,6 @@ export function buildAgentSystemPrompt(params: {
messageChannelOptions, messageChannelOptions,
inlineButtonsEnabled, inlineButtonsEnabled,
runtimeChannel, runtimeChannel,
channelActions: runtimeInfo?.channelActions,
}), }),
]; ];
@@ -582,7 +564,6 @@ export function buildRuntimeLine(
arch?: string; arch?: string;
node?: string; node?: string;
model?: string; model?: string;
defaultModel?: string;
}, },
runtimeChannel?: string, runtimeChannel?: string,
runtimeCapabilities: string[] = [], runtimeCapabilities: string[] = [],
@@ -598,7 +579,6 @@ export function buildRuntimeLine(
: "", : "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "", runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "", runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "", runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}` ? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`

View File

@@ -1,17 +1,85 @@
import { import {
Input, Input,
matchesKey,
type SelectItem, type SelectItem,
SelectList, SelectList,
type SelectListTheme, type SelectListTheme,
fuzzyFilter,
getEditorKeybindings, getEditorKeybindings,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
/**
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
* Returns score (lower = better) or null if no match.
*/
function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
if (queryLower.length === 0) return 0;
if (queryLower.length > textLower.length) return null;
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]);
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
}
if (isWordBoundary) score -= 10;
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
return queryIndex < queryLower.length ? null : score;
}
/**
* Filter items using pre-lowercased searchTextLower field.
* Supports space-separated tokens (all must match).
*/
function fuzzyFilterLower<T extends { searchTextLower?: string }>(
items: T[],
queryLower: string,
): T[] {
const trimmed = queryLower.trim();
if (!trimmed) return items;
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return items;
const results: { item: T; score: number }[] = [];
for (const item of items) {
const text = item.searchTextLower ?? "";
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const score = fuzzyMatchLower(token, text);
if (score !== null) {
totalScore += score;
} else {
allMatch = false;
break;
}
}
if (allMatch) results.push({ item, score: totalScore });
}
results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item);
}
export interface FilterableSelectItem extends SelectItem { export interface FilterableSelectItem extends SelectItem {
/** Additional searchable fields beyond label */ /** Additional searchable fields beyond label */
searchText?: string; searchText?: string;
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
searchTextLower?: string;
} }
export interface FilterableSelectListTheme extends SelectListTheme { export interface FilterableSelectListTheme extends SelectListTheme {
@@ -33,36 +101,29 @@ export class FilterableSelectList implements Component {
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
constructor( constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
items: FilterableSelectItem[], // Pre-compute searchTextLower for each item once
maxVisible: number, this.allItems = items.map((item) => {
theme: FilterableSelectListTheme, if (item.searchTextLower) return item;
) { const parts = [item.label];
this.allItems = items; if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText);
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
});
this.maxVisible = maxVisible; this.maxVisible = maxVisible;
this.theme = theme; this.theme = theme;
this.input = new Input(); this.input = new Input();
this.selectList = new SelectList(items, maxVisible, theme); this.selectList = new SelectList(this.allItems, maxVisible, theme);
}
private getSearchText(item: FilterableSelectItem): string {
const parts = [item.label];
if (item.description) parts.push(item.description);
if (item.searchText) parts.push(item.searchText);
return parts.join(" ");
} }
private applyFilter(): void { private applyFilter(): void {
const query = this.filterText.toLowerCase().trim(); const queryLower = this.filterText.toLowerCase();
if (!query) { if (!queryLower.trim()) {
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
return; return;
} }
const filtered = fuzzyFilterLower(this.allItems, queryLower);
const filtered = fuzzyFilter(this.allItems, query, (item) =>
this.getSearchText(item),
);
this.selectList = new SelectList(filtered, this.maxVisible, this.theme); this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
} }
@@ -91,17 +152,19 @@ export class FilterableSelectList implements Component {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
// Navigation keys go to select list // Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
if (keyData === "\x1b[A" || keyData === "\x1b[B" || keyData === "k" || keyData === "j") { if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
// Map vim keys to arrows for selectList this.selectList.handleInput("\x1b[A");
if (keyData === "k") keyData = "\x1b[A"; return;
if (keyData === "j") keyData = "\x1b[B"; }
this.selectList.handleInput(keyData);
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
this.selectList.handleInput("\x1b[B");
return; return;
} }
// Enter selects // Enter selects
if (keyData === "\r" || keyData === "\n") { if (matchesKey(keyData, "enter")) {
const selected = this.selectList.getSelectedItem(); const selected = this.selectList.getSelectedItem();
if (selected) { if (selected) {
this.onSelect?.(selected); this.onSelect?.(selected);

View File

@@ -1,6 +1,7 @@
import { import {
type Component, type Component,
fuzzyFilter, fuzzyFilter,
getEditorKeybindings,
Input, Input,
isKeyRelease, isKeyRelease,
matchesKey, matchesKey,
@@ -111,14 +112,6 @@ export class SearchableSelectList implements Component {
]; ];
} }
/**
* Check if query matches at a word boundary in text.
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
*/
private matchesWordBoundary(text: string, query: string): boolean {
return this.findWordBoundaryIndex(text, query) !== null;
}
private findWordBoundaryIndex(text: string, query: string): number | null { private findWordBoundaryIndex(text: string, query: string): number | null {
if (!query) return null; if (!query) return null;
const maxIndex = text.length - query.length; const maxIndex = text.length - query.length;
@@ -259,13 +252,13 @@ export class SearchableSelectList implements Component {
if (isKeyRelease(keyData)) return; if (isKeyRelease(keyData)) return;
// Navigation keys // Navigation keys
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) { if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange(); this.notifySelectionChange();
return; return;
} }
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) { if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange(); this.notifySelectionChange();
return; return;
@@ -279,7 +272,8 @@ export class SearchableSelectList implements Component {
return; return;
} }
if (matchesKey(keyData, "escape")) { const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) { if (this.onCancel) {
this.onCancel(); this.onCancel();
} }

View File

@@ -5,6 +5,7 @@ import {
resolveResponseUsageMode, resolveResponseUsageMode,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { formatRelativeTime } from "../utils/time-format.js";
import { helpText, parseCommand } from "./commands.js"; import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js"; import type { ChatLog } from "./components/chat-log.js";
import { import {
@@ -39,22 +40,6 @@ type CommandHandlerContext = {
formatSessionKey: (key: string) => string; formatSessionKey: (key: string) => string;
}; };
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export function createCommandHandlers(context: CommandHandlerContext) { export function createCommandHandlers(context: CommandHandlerContext) {
const { const {
client, client,
@@ -162,8 +147,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
const title = session.derivedTitle ?? session.displayName; const title = session.derivedTitle ?? session.displayName;
const formattedKey = formatSessionKey(session.key); const formattedKey = formatSessionKey(session.key);
// Avoid redundant "title (key)" when title matches key // Avoid redundant "title (key)" when title matches key
const label = const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
// Build description: time + message preview // Build description: time + message preview
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();

15
src/utils/time-format.ts Normal file
View File

@@ -0,0 +1,15 @@
export function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}