Skip to content

Commit b1e5a08

Browse files
authored
feat: Telescope-inspired SearchPicker for all search commands (#886)
## Summary Closes #866. Replace the flat fzf-style `SearchTUI` with a new `SearchPicker` component inspired by Neovim's Telescope plugin. All 12 search commands now show a split-pane interface with a results list on the left and a live preview of the selected item on the right. ### Key features - **Three-region layout**: branding line (matching workflow run tree style), search prompt, and split results/preview panes separated by clean separator lines - **Async preview loading** with 100ms debounce and LRU cache (10 items) — previews show actual content (YAML with syntax highlighting, JSON with syntax highlighting, rendered markdown reports) not just metadata - **Alternate screen buffer** so the picker disappears cleanly on exit, leaving only the selected item's detail in scrollback - **Graceful degradation** across four tiers (bordered-split → stacked → inline → minimal) based on terminal dimensions - **Ctrl-u/d** scrolls the preview pane, stops at end of content - **Domain-specific action keys** (e.g. `r` to run a workflow, `i` to install an extension) ### Architecture - New shared components in `renderers/components/`: `SearchPicker`, `PickerBorders`, `ResultsList`, `PreviewPane`, `HelpBar` - New hooks: `usePreviewFetch` (debounced async + LRU), `usePreviewScroll` - Pure `computePickerLayout()` function for tier selection (15 unit tests) - `LruCache` with 7 unit tests - All search renderers migrated from `renderInteractiveSearch` to `renderInteractivePicker` with `fetchPreview` callbacks - Everything in the `renderers/` pattern, nothing in legacy `output/` ### Simplifications - **Extension search**: eliminated the two-phase detail view + while-loop back pattern - **Workflow search**: removed action select menu and in-process execution; `r` key shells out to `swamp workflow run` instead; preview shows raw YAML with syntax highlighting ### Commands migrated | Command | Preview content | |---------|----------------| | `model search` | Methods with arg types, data output specs | | `data search` | Actual file content (JSON highlighted, markdown rendered) | | `extension search` | Full description, platforms, labels, dates | | `type search` | Methods, global arguments schema | | `vault search` | Vault config as formatted JSON | | `vault type search` | Type, name, description | | `workflow search` | Raw YAML with syntax highlighting | | `workflow run search` | Job/step status tree with icons | | `workflow history search` | Job/step status tree with icons | | `model output search` | Provenance, artifacts, errors | | `model method history search` | Same as model output | | `report search` | Rendered markdown report content | ## Test Plan - All 3574 existing tests pass - 22 new unit tests (15 for picker layout tiers, 7 for LRU cache) - Manual testing of all 12 search commands at various terminal sizes - Verified: alternate screen buffer, scrollback output, Ctrl-u/d scroll, action keys, cancel behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 208a2da commit b1e5a08

31 files changed

Lines changed: 3893 additions & 735 deletions

src/cli/commands/data_search.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
type DataSearchItem,
2828
parseTags,
2929
} from "../../libswamp/mod.ts";
30-
import { createDataSearchRenderer } from "../../presentation/renderers/data_search.tsx";
30+
import {
31+
createDataSearchRenderer,
32+
type DataPreviewDetail,
33+
} from "../../presentation/renderers/data_search.tsx";
3134
import { renderDataGet } from "../../presentation/renderers/data_get.ts";
3235
import {
3336
createContext,
@@ -46,6 +49,53 @@ import type { FileSystemUnifiedDataRepository } from "../../infrastructure/persi
4649
// deno-lint-ignore no-explicit-any
4750
type AnyOptions = any;
4851

52+
/**
53+
* Creates a fetchPreview closure for the data search picker.
54+
* Reads data content from disk for display in the preview pane.
55+
*/
56+
function createDataFetchPreview(
57+
dataRepo: FileSystemUnifiedDataRepository,
58+
repoDir: string,
59+
): (item: DataSearchItem) => Promise<DataPreviewDetail> {
60+
return async (item: DataSearchItem): Promise<DataPreviewDetail> => {
61+
const modelType = ModelType.create(item.modelType);
62+
const absoluteContentPath = dataRepo.getContentPath(
63+
modelType,
64+
item.modelId,
65+
item.name,
66+
item.version,
67+
);
68+
const contentPath = toRelativePath(repoDir, absoluteContentPath);
69+
70+
// Only read text content for preview
71+
const isText = item.contentType.startsWith("text/") ||
72+
item.contentType === "application/json" ||
73+
item.contentType === "application/yaml" ||
74+
item.contentType === "application/x-yaml";
75+
76+
if (!isText) {
77+
return { content: undefined, contentPath };
78+
}
79+
80+
try {
81+
const rawContent = await dataRepo.getContent(
82+
modelType,
83+
item.modelId,
84+
item.name,
85+
item.version,
86+
);
87+
if (rawContent) {
88+
const content = new TextDecoder().decode(rawContent);
89+
return { content, contentPath };
90+
}
91+
} catch {
92+
// Silently handle read errors
93+
}
94+
95+
return { content: undefined, contentPath };
96+
};
97+
}
98+
4999
/**
50100
* Fetches and displays full data details after selection from interactive search.
51101
*/
@@ -185,7 +235,12 @@ export const dataSearchCommand = new Command()
185235
findDefinitionByIdOrName(definitionRepo, idOrName),
186236
};
187237

188-
const renderer = createDataSearchRenderer(effectiveMode);
238+
const repoDir = options.repoDir ?? ".";
239+
const fetchPreview = effectiveMode === "log"
240+
? createDataFetchPreview(dataRepo, repoDir)
241+
: undefined;
242+
243+
const renderer = createDataSearchRenderer(effectiveMode, fetchPreview);
189244
await consumeStream(
190245
dataSearch(libCtx, deps, {
191246
query,
@@ -207,8 +262,11 @@ export const dataSearchCommand = new Command()
207262

208263
const selected = renderer.selectedItem();
209264
if (selected) {
210-
const repoDir = options.repoDir ?? ".";
211-
await displayDataDetail(selected, dataRepo, repoDir, effectiveMode);
265+
// In JSON mode, display full data detail after selection
266+
if (effectiveMode === "json") {
267+
await displayDataDetail(selected, dataRepo, repoDir, effectiveMode);
268+
}
269+
// In interactive mode, scrollback from the picker already has the detail
212270
}
213271

214272
ctx.logger.debug("Data search command completed");

src/cli/commands/model_output_search.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import {
2323
createLibSwampContext,
2424
createModelOutputGetDeps,
2525
modelOutputGet,
26+
type ModelOutputGetData,
2627
modelOutputSearch,
2728
type ModelOutputSearchDeps,
29+
type ModelOutputSearchItem,
2830
} from "../../libswamp/mod.ts";
2931
import { createModelOutputSearchRenderer } from "../../presentation/renderers/model_output_search.tsx";
3032
import { createModelOutputGetRenderer } from "../../presentation/renderers/model_output_get.ts";
@@ -40,6 +42,33 @@ import { createDefinitionId } from "../../domain/definitions/definition.ts";
4042
// deno-lint-ignore no-explicit-any
4143
type AnyOptions = any;
4244

45+
/**
46+
* Creates a fetchPreview closure that fetches full model output detail data.
47+
* This bridges the presentation layer to the libswamp modelOutputGet application
48+
* service, capturing the repoDir dependency.
49+
*/
50+
function createOutputFetchPreview(
51+
repoDir: string,
52+
): (item: ModelOutputSearchItem) => Promise<ModelOutputGetData> {
53+
const libCtx = createLibSwampContext();
54+
const getDeps = createModelOutputGetDeps(repoDir);
55+
56+
return async (item: ModelOutputSearchItem): Promise<ModelOutputGetData> => {
57+
let result: ModelOutputGetData | undefined;
58+
await consumeStream(modelOutputGet(libCtx, getDeps, item.id), {
59+
resolving: () => {},
60+
completed: (e) => {
61+
result = e.data;
62+
},
63+
error: () => {},
64+
});
65+
if (!result) {
66+
throw new Error(`Output not found: ${item.id}`);
67+
}
68+
return result;
69+
};
70+
}
71+
4372
export const modelOutputSearchCommand = new Command()
4473
.name("search")
4574
.description("Search for model outputs")
@@ -69,7 +98,15 @@ export const modelOutputSearchCommand = new Command()
6998
),
7099
};
71100

72-
const renderer = createModelOutputSearchRenderer(effectiveMode);
101+
const repoDir = options.repoDir ?? ".";
102+
const fetchPreview = effectiveMode === "log"
103+
? createOutputFetchPreview(repoDir)
104+
: undefined;
105+
106+
const renderer = createModelOutputSearchRenderer(
107+
effectiveMode,
108+
fetchPreview,
109+
);
73110
await consumeStream(
74111
modelOutputSearch(libCtx, deps, { query }),
75112
renderer.handlers(),
@@ -78,12 +115,17 @@ export const modelOutputSearchCommand = new Command()
78115
const selected = renderer.selectedItem();
79116
if (selected) {
80117
ctx.logger.debug`Selected output: ${selected.id}`;
81-
const getRenderer = createModelOutputGetRenderer(effectiveMode);
82-
const getDeps = createModelOutputGetDeps(options.repoDir ?? ".");
83-
await consumeStream(
84-
modelOutputGet(libCtx, getDeps, selected.id),
85-
getRenderer.handlers(),
86-
);
118+
// In JSON mode, still display the full output get after auto-select
119+
if (effectiveMode === "json") {
120+
const getRenderer = createModelOutputGetRenderer(effectiveMode);
121+
const getDeps = createModelOutputGetDeps(repoDir);
122+
await consumeStream(
123+
modelOutputGet(libCtx, getDeps, selected.id),
124+
getRenderer.handlers(),
125+
);
126+
}
127+
// In interactive mode, the scrollback from the picker already contains
128+
// the output detail, so no additional modelOutputGet call is needed.
87129
} else {
88130
ctx.logger.debug`Search cancelled`;
89131
}

src/cli/commands/model_search.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import {
2323
createLibSwampContext,
2424
createModelGetDeps,
2525
modelGet,
26+
type ModelGetData,
2627
modelSearch,
2728
type ModelSearchDeps,
29+
type ModelSearchItem,
2830
} from "../../libswamp/mod.ts";
2931
import { createModelSearchRenderer } from "../../presentation/renderers/model_search.tsx";
3032
import { createModelGetRenderer } from "../../presentation/renderers/model_get.ts";
@@ -38,6 +40,33 @@ import { requireInitializedRepoReadOnly } from "../repo_context.ts";
3840
// deno-lint-ignore no-explicit-any
3941
type AnyOptions = any;
4042

43+
/**
44+
* Creates a fetchPreview closure that fetches full model detail data.
45+
* This bridges the presentation layer to the libswamp modelGet application
46+
* service, capturing the repoDir dependency.
47+
*/
48+
function createModelFetchPreview(
49+
repoDir: string,
50+
): (item: ModelSearchItem) => Promise<ModelGetData> {
51+
const libCtx = createLibSwampContext();
52+
const getDeps = createModelGetDeps(repoDir);
53+
54+
return async (item: ModelSearchItem): Promise<ModelGetData> => {
55+
let result: ModelGetData | undefined;
56+
await consumeStream(modelGet(libCtx, getDeps, item.name), {
57+
resolving: () => {},
58+
completed: (e) => {
59+
result = e.data;
60+
},
61+
error: () => {},
62+
});
63+
if (!result) {
64+
throw new Error(`Model not found: ${item.name}`);
65+
}
66+
return result;
67+
};
68+
}
69+
4170
export async function modelSearchAction(
4271
options: AnyOptions,
4372
query?: string,
@@ -56,7 +85,12 @@ export async function modelSearchAction(
5685
findAllGlobal: () => repoContext.definitionRepo.findAllGlobal(),
5786
};
5887

59-
const renderer = createModelSearchRenderer(effectiveMode);
88+
const repoDir = options.repoDir ?? ".";
89+
const fetchPreview = effectiveMode === "log"
90+
? createModelFetchPreview(repoDir)
91+
: undefined;
92+
93+
const renderer = createModelSearchRenderer(effectiveMode, fetchPreview);
6094
await consumeStream(
6195
modelSearch(libCtx, deps, { query }),
6296
renderer.handlers(),
@@ -65,12 +99,17 @@ export async function modelSearchAction(
6599
const selected = renderer.selectedItem();
66100
if (selected) {
67101
ctx.logger.debug`Selected model: ${selected.name} (${selected.id})`;
68-
const getRenderer = createModelGetRenderer(effectiveMode);
69-
const getDeps = createModelGetDeps(options.repoDir ?? ".");
70-
await consumeStream(
71-
modelGet(libCtx, getDeps, selected.name),
72-
getRenderer.handlers(),
73-
);
102+
// In JSON mode, still display the full model get output after auto-select
103+
if (effectiveMode === "json") {
104+
const getRenderer = createModelGetRenderer(effectiveMode);
105+
const getDeps = createModelGetDeps(repoDir);
106+
await consumeStream(
107+
modelGet(libCtx, getDeps, selected.name),
108+
getRenderer.handlers(),
109+
);
110+
}
111+
// In interactive mode, the scrollback from the picker already contains
112+
// the model detail, so no additional modelGet call is needed.
74113
} else {
75114
ctx.logger.debug`Search cancelled`;
76115
}

src/cli/commands/report_search.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type ReportGetDeps,
3636
reportSearch,
3737
type ReportSearchDeps,
38+
type StoredReportDetail,
3839
type StoredReportSummary,
3940
} from "../../libswamp/mod.ts";
4041
import type { RepositoryContext } from "../../infrastructure/persistence/repository_factory.ts";
@@ -94,6 +95,41 @@ function buildGetDeps(repoContext: RepositoryContext): ReportGetDeps {
9495
};
9596
}
9697

98+
/**
99+
* Creates a fetchPreview closure that fetches full report detail data.
100+
* This bridges the presentation layer to the libswamp reportGet application
101+
* service, capturing the repository context dependency.
102+
*/
103+
function createReportFetchPreview(
104+
repoContext: RepositoryContext,
105+
): (item: StoredReportSummary) => Promise<StoredReportDetail> {
106+
const libCtx = createLibSwampContext();
107+
const getDeps = buildGetDeps(repoContext);
108+
109+
return async (item: StoredReportSummary): Promise<StoredReportDetail> => {
110+
let result: StoredReportDetail | undefined;
111+
await consumeStream(
112+
reportGet(libCtx, getDeps, {
113+
reportName: item.reportName,
114+
model: item.workflowName ? undefined : item.modelName,
115+
workflow: item.workflowName,
116+
variant: item.varySuffix,
117+
}),
118+
{
119+
resolving: () => {},
120+
completed: (e) => {
121+
result = e.data;
122+
},
123+
error: () => {},
124+
},
125+
);
126+
if (!result) {
127+
throw new Error(`Report not found: ${item.reportName}`);
128+
}
129+
return result;
130+
};
131+
}
132+
97133
/**
98134
* Fetches and displays full report content for a selected summary.
99135
*/
@@ -146,9 +182,14 @@ export const reportSearchCommand = new Command()
146182

147183
const libCtx = createLibSwampContext({ logger: ctx.logger });
148184

185+
const fetchPreview = effectiveMode === "log"
186+
? createReportFetchPreview(repoContext)
187+
: undefined;
188+
149189
const searchRenderer = createReportSearchRenderer(
150190
effectiveMode,
151191
query ?? "",
192+
fetchPreview,
152193
);
153194
await consumeStream(
154195
reportSearch(libCtx, buildSearchDeps(repoContext), {
@@ -164,7 +205,17 @@ export const reportSearchCommand = new Command()
164205
const selected = searchRenderer.selectedItem();
165206
if (selected) {
166207
ctx.logger.debug`Selected report: ${selected.reportName}`;
167-
await displayReportDetail(selected, repoContext, libCtx, effectiveMode);
208+
// In JSON mode, still display the full report detail after auto-select
209+
if (effectiveMode === "json") {
210+
await displayReportDetail(
211+
selected,
212+
repoContext,
213+
libCtx,
214+
effectiveMode,
215+
);
216+
}
217+
// In interactive mode, the scrollback from the picker already contains
218+
// the report detail, so no additional displayReportDetail call is needed.
168219
} else {
169220
ctx.logger.debug`Search cancelled`;
170221
}

0 commit comments

Comments
 (0)