diff --git a/src/cli/commands/data_search.ts b/src/cli/commands/data_search.ts index fbde2920..34f1541d 100644 --- a/src/cli/commands/data_search.ts +++ b/src/cli/commands/data_search.ts @@ -27,7 +27,10 @@ import { type DataSearchItem, parseTags, } from "../../libswamp/mod.ts"; -import { createDataSearchRenderer } from "../../presentation/renderers/data_search.tsx"; +import { + createDataSearchRenderer, + type DataPreviewDetail, +} from "../../presentation/renderers/data_search.tsx"; import { renderDataGet } from "../../presentation/renderers/data_get.ts"; import { createContext, @@ -46,6 +49,53 @@ import type { FileSystemUnifiedDataRepository } from "../../infrastructure/persi // deno-lint-ignore no-explicit-any type AnyOptions = any; +/** + * Creates a fetchPreview closure for the data search picker. + * Reads data content from disk for display in the preview pane. + */ +function createDataFetchPreview( + dataRepo: FileSystemUnifiedDataRepository, + repoDir: string, +): (item: DataSearchItem) => Promise { + return async (item: DataSearchItem): Promise => { + const modelType = ModelType.create(item.modelType); + const absoluteContentPath = dataRepo.getContentPath( + modelType, + item.modelId, + item.name, + item.version, + ); + const contentPath = toRelativePath(repoDir, absoluteContentPath); + + // Only read text content for preview + const isText = item.contentType.startsWith("text/") || + item.contentType === "application/json" || + item.contentType === "application/yaml" || + item.contentType === "application/x-yaml"; + + if (!isText) { + return { content: undefined, contentPath }; + } + + try { + const rawContent = await dataRepo.getContent( + modelType, + item.modelId, + item.name, + item.version, + ); + if (rawContent) { + const content = new TextDecoder().decode(rawContent); + return { content, contentPath }; + } + } catch { + // Silently handle read errors + } + + return { content: undefined, contentPath }; + }; +} + /** * Fetches and displays full data details after selection from interactive search. */ @@ -185,7 +235,12 @@ export const dataSearchCommand = new Command() findDefinitionByIdOrName(definitionRepo, idOrName), }; - const renderer = createDataSearchRenderer(effectiveMode); + const repoDir = options.repoDir ?? "."; + const fetchPreview = effectiveMode === "log" + ? createDataFetchPreview(dataRepo, repoDir) + : undefined; + + const renderer = createDataSearchRenderer(effectiveMode, fetchPreview); await consumeStream( dataSearch(libCtx, deps, { query, @@ -207,8 +262,11 @@ export const dataSearchCommand = new Command() const selected = renderer.selectedItem(); if (selected) { - const repoDir = options.repoDir ?? "."; - await displayDataDetail(selected, dataRepo, repoDir, effectiveMode); + // In JSON mode, display full data detail after selection + if (effectiveMode === "json") { + await displayDataDetail(selected, dataRepo, repoDir, effectiveMode); + } + // In interactive mode, scrollback from the picker already has the detail } ctx.logger.debug("Data search command completed"); diff --git a/src/cli/commands/model_output_search.ts b/src/cli/commands/model_output_search.ts index eb0bf557..9cbc4861 100644 --- a/src/cli/commands/model_output_search.ts +++ b/src/cli/commands/model_output_search.ts @@ -23,8 +23,10 @@ import { createLibSwampContext, createModelOutputGetDeps, modelOutputGet, + type ModelOutputGetData, modelOutputSearch, type ModelOutputSearchDeps, + type ModelOutputSearchItem, } from "../../libswamp/mod.ts"; import { createModelOutputSearchRenderer } from "../../presentation/renderers/model_output_search.tsx"; import { createModelOutputGetRenderer } from "../../presentation/renderers/model_output_get.ts"; @@ -40,6 +42,33 @@ import { createDefinitionId } from "../../domain/definitions/definition.ts"; // deno-lint-ignore no-explicit-any type AnyOptions = any; +/** + * Creates a fetchPreview closure that fetches full model output detail data. + * This bridges the presentation layer to the libswamp modelOutputGet application + * service, capturing the repoDir dependency. + */ +function createOutputFetchPreview( + repoDir: string, +): (item: ModelOutputSearchItem) => Promise { + const libCtx = createLibSwampContext(); + const getDeps = createModelOutputGetDeps(repoDir); + + return async (item: ModelOutputSearchItem): Promise => { + let result: ModelOutputGetData | undefined; + await consumeStream(modelOutputGet(libCtx, getDeps, item.id), { + resolving: () => {}, + completed: (e) => { + result = e.data; + }, + error: () => {}, + }); + if (!result) { + throw new Error(`Output not found: ${item.id}`); + } + return result; + }; +} + export const modelOutputSearchCommand = new Command() .name("search") .description("Search for model outputs") @@ -69,7 +98,15 @@ export const modelOutputSearchCommand = new Command() ), }; - const renderer = createModelOutputSearchRenderer(effectiveMode); + const repoDir = options.repoDir ?? "."; + const fetchPreview = effectiveMode === "log" + ? createOutputFetchPreview(repoDir) + : undefined; + + const renderer = createModelOutputSearchRenderer( + effectiveMode, + fetchPreview, + ); await consumeStream( modelOutputSearch(libCtx, deps, { query }), renderer.handlers(), @@ -78,12 +115,17 @@ export const modelOutputSearchCommand = new Command() const selected = renderer.selectedItem(); if (selected) { ctx.logger.debug`Selected output: ${selected.id}`; - const getRenderer = createModelOutputGetRenderer(effectiveMode); - const getDeps = createModelOutputGetDeps(options.repoDir ?? "."); - await consumeStream( - modelOutputGet(libCtx, getDeps, selected.id), - getRenderer.handlers(), - ); + // In JSON mode, still display the full output get after auto-select + if (effectiveMode === "json") { + const getRenderer = createModelOutputGetRenderer(effectiveMode); + const getDeps = createModelOutputGetDeps(repoDir); + await consumeStream( + modelOutputGet(libCtx, getDeps, selected.id), + getRenderer.handlers(), + ); + } + // In interactive mode, the scrollback from the picker already contains + // the output detail, so no additional modelOutputGet call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/model_search.ts b/src/cli/commands/model_search.ts index 0e083f8b..07f170d3 100644 --- a/src/cli/commands/model_search.ts +++ b/src/cli/commands/model_search.ts @@ -23,8 +23,10 @@ import { createLibSwampContext, createModelGetDeps, modelGet, + type ModelGetData, modelSearch, type ModelSearchDeps, + type ModelSearchItem, } from "../../libswamp/mod.ts"; import { createModelSearchRenderer } from "../../presentation/renderers/model_search.tsx"; import { createModelGetRenderer } from "../../presentation/renderers/model_get.ts"; @@ -38,6 +40,33 @@ import { requireInitializedRepoReadOnly } from "../repo_context.ts"; // deno-lint-ignore no-explicit-any type AnyOptions = any; +/** + * Creates a fetchPreview closure that fetches full model detail data. + * This bridges the presentation layer to the libswamp modelGet application + * service, capturing the repoDir dependency. + */ +function createModelFetchPreview( + repoDir: string, +): (item: ModelSearchItem) => Promise { + const libCtx = createLibSwampContext(); + const getDeps = createModelGetDeps(repoDir); + + return async (item: ModelSearchItem): Promise => { + let result: ModelGetData | undefined; + await consumeStream(modelGet(libCtx, getDeps, item.name), { + resolving: () => {}, + completed: (e) => { + result = e.data; + }, + error: () => {}, + }); + if (!result) { + throw new Error(`Model not found: ${item.name}`); + } + return result; + }; +} + export async function modelSearchAction( options: AnyOptions, query?: string, @@ -56,7 +85,12 @@ export async function modelSearchAction( findAllGlobal: () => repoContext.definitionRepo.findAllGlobal(), }; - const renderer = createModelSearchRenderer(effectiveMode); + const repoDir = options.repoDir ?? "."; + const fetchPreview = effectiveMode === "log" + ? createModelFetchPreview(repoDir) + : undefined; + + const renderer = createModelSearchRenderer(effectiveMode, fetchPreview); await consumeStream( modelSearch(libCtx, deps, { query }), renderer.handlers(), @@ -65,12 +99,17 @@ export async function modelSearchAction( const selected = renderer.selectedItem(); if (selected) { ctx.logger.debug`Selected model: ${selected.name} (${selected.id})`; - const getRenderer = createModelGetRenderer(effectiveMode); - const getDeps = createModelGetDeps(options.repoDir ?? "."); - await consumeStream( - modelGet(libCtx, getDeps, selected.name), - getRenderer.handlers(), - ); + // In JSON mode, still display the full model get output after auto-select + if (effectiveMode === "json") { + const getRenderer = createModelGetRenderer(effectiveMode); + const getDeps = createModelGetDeps(repoDir); + await consumeStream( + modelGet(libCtx, getDeps, selected.name), + getRenderer.handlers(), + ); + } + // In interactive mode, the scrollback from the picker already contains + // the model detail, so no additional modelGet call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/report_search.ts b/src/cli/commands/report_search.ts index 379d2eb0..ccf5b944 100644 --- a/src/cli/commands/report_search.ts +++ b/src/cli/commands/report_search.ts @@ -35,6 +35,7 @@ import { type ReportGetDeps, reportSearch, type ReportSearchDeps, + type StoredReportDetail, type StoredReportSummary, } from "../../libswamp/mod.ts"; import type { RepositoryContext } from "../../infrastructure/persistence/repository_factory.ts"; @@ -94,6 +95,41 @@ function buildGetDeps(repoContext: RepositoryContext): ReportGetDeps { }; } +/** + * Creates a fetchPreview closure that fetches full report detail data. + * This bridges the presentation layer to the libswamp reportGet application + * service, capturing the repository context dependency. + */ +function createReportFetchPreview( + repoContext: RepositoryContext, +): (item: StoredReportSummary) => Promise { + const libCtx = createLibSwampContext(); + const getDeps = buildGetDeps(repoContext); + + return async (item: StoredReportSummary): Promise => { + let result: StoredReportDetail | undefined; + await consumeStream( + reportGet(libCtx, getDeps, { + reportName: item.reportName, + model: item.workflowName ? undefined : item.modelName, + workflow: item.workflowName, + variant: item.varySuffix, + }), + { + resolving: () => {}, + completed: (e) => { + result = e.data; + }, + error: () => {}, + }, + ); + if (!result) { + throw new Error(`Report not found: ${item.reportName}`); + } + return result; + }; +} + /** * Fetches and displays full report content for a selected summary. */ @@ -146,9 +182,14 @@ export const reportSearchCommand = new Command() const libCtx = createLibSwampContext({ logger: ctx.logger }); + const fetchPreview = effectiveMode === "log" + ? createReportFetchPreview(repoContext) + : undefined; + const searchRenderer = createReportSearchRenderer( effectiveMode, query ?? "", + fetchPreview, ); await consumeStream( reportSearch(libCtx, buildSearchDeps(repoContext), { @@ -164,7 +205,17 @@ export const reportSearchCommand = new Command() const selected = searchRenderer.selectedItem(); if (selected) { ctx.logger.debug`Selected report: ${selected.reportName}`; - await displayReportDetail(selected, repoContext, libCtx, effectiveMode); + // In JSON mode, still display the full report detail after auto-select + if (effectiveMode === "json") { + await displayReportDetail( + selected, + repoContext, + libCtx, + effectiveMode, + ); + } + // In interactive mode, the scrollback from the picker already contains + // the report detail, so no additional displayReportDetail call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/type_search.ts b/src/cli/commands/type_search.ts index 858191b8..ae74a086 100644 --- a/src/cli/commands/type_search.ts +++ b/src/cli/commands/type_search.ts @@ -23,8 +23,10 @@ import { createLibSwampContext, createTypeDescribeDeps, typeDescribe, + type TypeDescribeData, typeSearch, type TypeSearchDeps, + type TypeSearchItem, } from "../../libswamp/mod.ts"; import { createTypeSearchRenderer } from "../../presentation/renderers/type_search.tsx"; import { createTypeDescribeRenderer } from "../../presentation/renderers/type_describe.ts"; @@ -39,6 +41,36 @@ import { modelRegistry } from "../../domain/models/model.ts"; // deno-lint-ignore no-explicit-any type AnyOptions = any; +/** + * Creates a fetchPreview closure that fetches full type detail data. + * This bridges the presentation layer to the libswamp typeDescribe application + * service. + */ +function createTypeFetchPreview(): ( + item: TypeSearchItem, +) => Promise { + const libCtx = createLibSwampContext(); + const describeDeps = createTypeDescribeDeps(); + + return async (item: TypeSearchItem): Promise => { + let result: TypeDescribeData | undefined; + await consumeStream( + typeDescribe(libCtx, describeDeps, ModelType.create(item.normalized)), + { + resolving: () => {}, + completed: (e) => { + result = e.data; + }, + error: () => {}, + }, + ); + if (!result) { + throw new Error(`Type not found: ${item.normalized}`); + } + return result; + }; +} + export const typeSearchCommand = new Command() .name("search") .description("Search for model types") @@ -53,7 +85,11 @@ export const typeSearchCommand = new Command() getRegisteredTypes: () => modelRegistry.types(), }; - const renderer = createTypeSearchRenderer(effectiveMode); + const fetchPreview = effectiveMode === "log" + ? createTypeFetchPreview() + : undefined; + + const renderer = createTypeSearchRenderer(effectiveMode, fetchPreview); await consumeStream( typeSearch(libCtx, deps, { query }), renderer.handlers(), @@ -62,16 +98,21 @@ export const typeSearchCommand = new Command() const selected = renderer.selectedItem(); if (selected) { ctx.logger.debug`Selected type: ${selected.normalized}`; - const describeRenderer = createTypeDescribeRenderer(effectiveMode); - const describeDeps = createTypeDescribeDeps(); - await consumeStream( - typeDescribe( - libCtx, - describeDeps, - ModelType.create(selected.normalized), - ), - describeRenderer.handlers(), - ); + // In JSON mode, still display the full type describe output after auto-select + if (effectiveMode === "json") { + const describeRenderer = createTypeDescribeRenderer(effectiveMode); + const describeDeps = createTypeDescribeDeps(); + await consumeStream( + typeDescribe( + libCtx, + describeDeps, + ModelType.create(selected.normalized), + ), + describeRenderer.handlers(), + ); + } + // In interactive mode, the scrollback from the picker already contains + // the type detail, so no additional typeDescribe call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/vault_search.ts b/src/cli/commands/vault_search.ts index 2b473526..cf67d754 100644 --- a/src/cli/commands/vault_search.ts +++ b/src/cli/commands/vault_search.ts @@ -23,8 +23,10 @@ import { createLibSwampContext, createVaultDescribeDeps, vaultDescribe, + type VaultDescribeData, vaultSearch, type VaultSearchDeps, + type VaultSearchItem, } from "../../libswamp/mod.ts"; import { createVaultSearchRenderer } from "../../presentation/renderers/vault_search.tsx"; import { createVaultDescribeRenderer } from "../../presentation/renderers/vault_describe.ts"; @@ -38,6 +40,33 @@ import { requireInitializedRepoReadOnly } from "../repo_context.ts"; // deno-lint-ignore no-explicit-any type AnyOptions = any; +/** + * Creates a fetchPreview closure that fetches full vault detail data. + * This bridges the presentation layer to the libswamp vaultDescribe application + * service, capturing the repoDir dependency. + */ +function createVaultFetchPreview( + repoDir: string, +): (item: VaultSearchItem) => Promise { + const libCtx = createLibSwampContext(); + const describeDeps = createVaultDescribeDeps(repoDir); + + return async (item: VaultSearchItem): Promise => { + let result: VaultDescribeData | undefined; + await consumeStream(vaultDescribe(libCtx, describeDeps, item.name), { + resolving: () => {}, + completed: (e) => { + result = e.data; + }, + error: () => {}, + }); + if (!result) { + throw new Error(`Vault not found: ${item.name}`); + } + return result; + }; +} + export const vaultSearchCommand = new Command() .name("search") .description("Search for vaults in the repository") @@ -58,7 +87,12 @@ export const vaultSearchCommand = new Command() findAllVaults: () => repoContext.vaultConfigRepo.findAll(), }; - const renderer = createVaultSearchRenderer(effectiveMode); + const repoDir = options.repoDir ?? "."; + const fetchPreview = effectiveMode === "log" + ? createVaultFetchPreview(repoDir) + : undefined; + + const renderer = createVaultSearchRenderer(effectiveMode, fetchPreview); await consumeStream( vaultSearch(libCtx, deps, { query }), renderer.handlers(), @@ -67,12 +101,17 @@ export const vaultSearchCommand = new Command() const selected = renderer.selectedItem(); if (selected) { ctx.logger.debug`Selected vault: ${selected.name}`; - const describeRenderer = createVaultDescribeRenderer(effectiveMode); - const describeDeps = createVaultDescribeDeps(options.repoDir ?? "."); - await consumeStream( - vaultDescribe(libCtx, describeDeps, selected.name), - describeRenderer.handlers(), - ); + // In JSON mode, still display the full vault describe output after auto-select + if (effectiveMode === "json") { + const describeRenderer = createVaultDescribeRenderer(effectiveMode); + const describeDeps = createVaultDescribeDeps(repoDir); + await consumeStream( + vaultDescribe(libCtx, describeDeps, selected.name), + describeRenderer.handlers(), + ); + } + // In interactive mode, the scrollback from the picker already contains + // the vault detail, so no additional vaultDescribe call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/workflow_history_search.ts b/src/cli/commands/workflow_history_search.ts index 87688388..d417fb95 100644 --- a/src/cli/commands/workflow_history_search.ts +++ b/src/cli/commands/workflow_history_search.ts @@ -25,10 +25,12 @@ import { type StepRunView, workflowHistorySearch, type WorkflowHistorySearchDeps, + type WorkflowHistorySearchItem, type WorkflowRunView, } from "../../libswamp/mod.ts"; import { renderWorkflowRunDisplay } from "../../presentation/renderers/workflow_run_display.ts"; import { createWorkflowHistorySearchRenderer } from "../../presentation/renderers/workflow_history_search.tsx"; +import type { YamlWorkflowRunRepository } from "../../infrastructure/persistence/yaml_workflow_run_repository.ts"; import { createContext, type GlobalOptions, @@ -86,6 +88,32 @@ function toRunData(run: WorkflowRun, path?: string): WorkflowRunView { }; } +/** + * Creates a fetchPreview closure that fetches full workflow run detail data. + * This bridges the presentation layer to the run repository, converting + * WorkflowRun to WorkflowRunView. + */ +function createHistoryFetchPreview( + runRepo: YamlWorkflowRunRepository, +): (item: WorkflowHistorySearchItem) => Promise { + return async ( + item: WorkflowHistorySearchItem, + ): Promise => { + const run = await runRepo.findById( + createWorkflowId(item.workflowId), + createWorkflowRunId(item.runId), + ); + if (!run) { + throw new Error(`Run not found: ${item.runId}`); + } + const path = runRepo.getPath( + createWorkflowId(item.workflowId), + createWorkflowRunId(item.runId), + ); + return toRunData(run, path); + }; +} + export async function workflowHistorySearchAction( options: AnyOptions, query?: string, @@ -111,7 +139,14 @@ export async function workflowHistorySearchAction( runRepo.findAllByWorkflowId(createWorkflowId(id)), }; - const renderer = createWorkflowHistorySearchRenderer(effectiveMode); + const fetchPreview = effectiveMode === "log" + ? createHistoryFetchPreview(runRepo) + : undefined; + + const renderer = createWorkflowHistorySearchRenderer( + effectiveMode, + fetchPreview, + ); await consumeStream( workflowHistorySearch(libCtx, deps, { query }), renderer.handlers(), @@ -120,19 +155,23 @@ export async function workflowHistorySearchAction( const selected = renderer.selectedItem(); if (selected) { ctx.logger.debug`Selected run: ${selected.runId}`; - // Display the run details - const run = await runRepo.findById( - createWorkflowId(selected.workflowId), - createWorkflowRunId(selected.runId), - ); - if (run) { - const path = runRepo.getPath( + // In JSON mode, still display the full run details after auto-select + if (effectiveMode === "json") { + const run = await runRepo.findById( createWorkflowId(selected.workflowId), createWorkflowRunId(selected.runId), ); - const runData = toRunData(run, path); - renderWorkflowRunDisplay(runData, effectiveMode); + if (run) { + const path = runRepo.getPath( + createWorkflowId(selected.workflowId), + createWorkflowRunId(selected.runId), + ); + const runData = toRunData(run, path); + renderWorkflowRunDisplay(runData, effectiveMode); + } } + // In interactive mode, the scrollback from the picker already contains + // the run detail, so no additional findById+render call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/workflow_run_search.ts b/src/cli/commands/workflow_run_search.ts index f195baf4..98bff4e3 100644 --- a/src/cli/commands/workflow_run_search.ts +++ b/src/cli/commands/workflow_run_search.ts @@ -26,10 +26,12 @@ import { type StepRunView, workflowRunSearch, type WorkflowRunSearchDeps, + type WorkflowRunSearchItem, type WorkflowRunView, } from "../../libswamp/mod.ts"; import { renderWorkflowRunDisplay } from "../../presentation/renderers/workflow_run_display.ts"; import { createWorkflowRunSearchRenderer } from "../../presentation/renderers/workflow_run_search.tsx"; +import type { YamlWorkflowRunRepository } from "../../infrastructure/persistence/yaml_workflow_run_repository.ts"; import { createContext, type GlobalOptions, @@ -87,6 +89,30 @@ function toRunData(run: WorkflowRun, path?: string): WorkflowRunView { }; } +/** + * Creates a fetchPreview closure that fetches full workflow run detail data. + * This bridges the presentation layer to the run repository, converting + * WorkflowRun to WorkflowRunView. + */ +function createRunFetchPreview( + runRepo: YamlWorkflowRunRepository, +): (item: WorkflowRunSearchItem) => Promise { + return async (item: WorkflowRunSearchItem): Promise => { + const run = await runRepo.findById( + createWorkflowId(item.workflowId), + createWorkflowRunId(item.runId), + ); + if (!run) { + throw new Error(`Run not found: ${item.runId}`); + } + const path = runRepo.getPath( + createWorkflowId(item.workflowId), + createWorkflowRunId(item.runId), + ); + return toRunData(run, path); + }; +} + export async function workflowRunSearchAction( options: AnyOptions, query?: string, @@ -117,7 +143,14 @@ export async function workflowRunSearchAction( runRepo.findAllByWorkflowId(createWorkflowId(id)), }; - const renderer = createWorkflowRunSearchRenderer(effectiveMode); + const fetchPreview = effectiveMode === "log" + ? createRunFetchPreview(runRepo) + : undefined; + + const renderer = createWorkflowRunSearchRenderer( + effectiveMode, + fetchPreview, + ); await consumeStream( workflowRunSearch(libCtx, deps, { query, @@ -133,19 +166,23 @@ export async function workflowRunSearchAction( const selected = renderer.selectedItem(); if (selected) { ctx.logger.debug`Selected run: ${selected.runId}`; - // Display the run details - const run = await runRepo.findById( - createWorkflowId(selected.workflowId), - createWorkflowRunId(selected.runId), - ); - if (run) { - const path = runRepo.getPath( + // In JSON mode, still display the full run details after auto-select + if (effectiveMode === "json") { + const run = await runRepo.findById( createWorkflowId(selected.workflowId), createWorkflowRunId(selected.runId), ); - const runData = toRunData(run, path); - renderWorkflowRunDisplay(runData, effectiveMode); + if (run) { + const path = runRepo.getPath( + createWorkflowId(selected.workflowId), + createWorkflowRunId(selected.runId), + ); + const runData = toRunData(run, path); + renderWorkflowRunDisplay(runData, effectiveMode); + } } + // In interactive mode, the scrollback from the picker already contains + // the run detail, so no additional findById+render call is needed. } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/cli/commands/workflow_search.ts b/src/cli/commands/workflow_search.ts index 6e7f698c..212c01aa 100644 --- a/src/cli/commands/workflow_search.ts +++ b/src/cli/commands/workflow_search.ts @@ -24,46 +24,55 @@ import { type WorkflowGetData, workflowSearch, type WorkflowSearchDeps, - type WorkflowSearchItem, } from "../../libswamp/mod.ts"; -import { createWorkflowSearchRenderer } from "../../presentation/renderers/workflow_search.tsx"; +import { + createWorkflowSearchRenderer, + type WorkflowPreviewDetail, + type WorkflowPreviewFetcher, +} from "../../presentation/renderers/workflow_search.tsx"; import { renderWorkflowGet } from "../../presentation/renderers/workflow_get.ts"; -import { renderWorkflowActionSelect } from "../../presentation/renderers/workflow_action_select.tsx"; -import { renderInputFileSelect } from "../../presentation/renderers/input_file_select.tsx"; import { createContext, type GlobalOptions, interactiveOutputMode, } from "../context.ts"; -import { - requireInitializedRepo, - requireInitializedRepoReadOnly, -} from "../repo_context.ts"; +import { requireInitializedRepoReadOnly } from "../repo_context.ts"; import { UserError } from "../../domain/errors.ts"; import type { WorkflowRepository } from "../../domain/workflows/repositories.ts"; -import type { YamlWorkflowRunRepository } from "../../infrastructure/persistence/yaml_workflow_run_repository.ts"; -import { WorkflowExecutionService } from "../../domain/workflows/execution_service.ts"; -import { parseInputs } from "../input_parser.ts"; -import { InputValidationService } from "../../domain/inputs/mod.ts"; -import type { InputsSchema } from "../../domain/definitions/definition.ts"; // deno-lint-ignore no-explicit-any type AnyOptions = any; /** - * Displays the workflow get output for a selected workflow. + * Creates a fetchPreview closure that reads the workflow YAML file from disk. + */ +function createWorkflowFetchPreview( + repo: WorkflowRepository, +): WorkflowPreviewFetcher { + return async ( + item, + ): Promise => { + const workflow = await repo.findByName(item.name); + if (!workflow) { + throw new Error(`Workflow not found: ${item.name}`); + } + const path = repo.getPath(workflow.id); + const yaml = await Deno.readTextFile(path); + return { yaml, name: workflow.name }; + }; +} + +/** + * Displays the workflow get output for JSON mode. */ async function displayWorkflowGet( - item: WorkflowSearchItem, + name: string, repo: WorkflowRepository, - options: AnyOptions, + outputMode: "json" | "log", ): Promise { - const ctx = createContext(options as GlobalOptions, ["workflow", "search"]); - const effectiveMode = interactiveOutputMode(ctx); - const workflow = await repo.findByName(item.name); - + const workflow = await repo.findByName(name); if (!workflow) { - throw new UserError(`Workflow not found: ${item.name}`); + throw new UserError(`Workflow not found: ${name}`); } const data: WorkflowGetData = { @@ -83,148 +92,7 @@ async function displayWorkflowGet( path: repo.getPath(workflow.id), }; - renderWorkflowGet(data, effectiveMode); -} - -/** - * Gets the required input names from a workflow's input schema. - */ -function getRequiredInputs(schema: InputsSchema | undefined): string[] { - if (!schema) return []; - - const properties = schema.properties ?? schema; - const required = schema.required ?? []; - - // Find required inputs that don't have defaults - const missingRequired: string[] = []; - for (const key of required) { - const propSchema = properties[key]; - if ( - propSchema && - typeof propSchema === "object" && - propSchema !== null && - !("default" in propSchema) - ) { - missingRequired.push(key); - } - } - - return missingRequired; -} - -/** - * Checks if all required inputs have defaults. - */ -function hasAllDefaults(schema: InputsSchema | undefined): boolean { - if (!schema) return true; - - const properties = schema.properties ?? schema; - const required = schema.required ?? []; - - for (const key of required) { - const propSchema = properties[key]; - if ( - propSchema && - typeof propSchema === "object" && - propSchema !== null && - !("default" in propSchema) - ) { - return false; - } - } - - return true; -} - -/** - * Executes a workflow after search selection. - */ -async function executeWorkflowFromSearch( - item: WorkflowSearchItem, - repo: WorkflowRepository, - runRepo: YamlWorkflowRunRepository, - repoDir: string, - options: AnyOptions, -): Promise { - const ctx = createContext(options as GlobalOptions, ["workflow", "search"]); - const effectiveMode = interactiveOutputMode(ctx); - const workflow = await repo.findByName(item.name); - - if (!workflow) { - throw new UserError(`Workflow not found: ${item.name}`); - } - - // Get required inputs info - const requiredInputs = getRequiredInputs(workflow.inputs); - const allHaveDefaults = hasAllDefaults(workflow.inputs); - - // Show input file selection if workflow has inputs - let inputFilePath: string | undefined; - - if (workflow.inputs && Object.keys(workflow.inputs).length > 0) { - const selection = await renderInputFileSelect( - { - workflowName: workflow.name, - requiredInputs, - hasDefaults: allHaveDefaults, - searchDir: repoDir, - }, - effectiveMode, - ); - - if (!selection) { - // User cancelled - ctx.logger.debug`Input file selection cancelled`; - return; - } - - if (selection.type === "file" && selection.path) { - inputFilePath = selection.path; - } - } - - // Parse inputs from selected file - const { inputs } = await parseInputs({ - inputFile: inputFilePath, - }); - - // Validate inputs - if (workflow.inputs) { - const validationService = new InputValidationService(); - const inputsWithDefaults = validationService.applyDefaults( - inputs, - workflow.inputs, - ); - const validationResult = validationService.validate( - inputsWithDefaults, - workflow.inputs, - ); - if (!validationResult.valid) { - const errorMessages = validationResult.errors - .map((e) => ` ${e.message}`) - .join("\n"); - throw new UserError(`Input validation failed:\n${errorMessages}`); - } - // Use inputs with defaults applied - Object.assign(inputs, inputsWithDefaults); - } - - // Execute workflow - const executionService = new WorkflowExecutionService( - repo, - runRepo, - repoDir, - ); - - const run = await executionService.execute(workflow.name, { - inputs, - }); - - ctx.logger.debug`Workflow run completed: status=${run.status}`; - - if (run.status === "failed") { - Deno.exit(1); - } + renderWorkflowGet(data, outputMode); } export const workflowSearchCommand = new Command() @@ -238,23 +106,23 @@ export const workflowSearchCommand = new Command() const libCtx = createLibSwampContext(); ctx.logger.debug`Searching workflows with query: ${query ?? "(none)"}`; - // Interactive mode can trigger workflow execution (a write operation), - // so it needs the full lock. JSON mode is always read-only. - const initRepo = effectiveMode === "log" - ? requireInitializedRepo - : requireInitializedRepoReadOnly; - const { repoDir, repoContext } = await initRepo({ + // Search is always read-only. Execution (if "r" is pressed) happens via + // subprocess, so we don't need a write lock. + const { repoContext } = await requireInitializedRepoReadOnly({ repoDir: options.repoDir ?? ".", outputMode: effectiveMode, }); const repo = repoContext.workflowRepo; - const runRepo = repoContext.workflowRunRepo; const deps: WorkflowSearchDeps = { findAllWorkflows: () => repo.findAll(), }; - const renderer = createWorkflowSearchRenderer(effectiveMode); + const fetchPreview = effectiveMode === "log" + ? createWorkflowFetchPreview(repo) + : undefined; + + const renderer = createWorkflowSearchRenderer(effectiveMode, fetchPreview); await consumeStream( workflowSearch(libCtx, deps, { query }), renderer.handlers(), @@ -264,42 +132,29 @@ export const workflowSearchCommand = new Command() if (selected) { ctx.logger.debug`Selected workflow: ${selected.name}`; - - if (effectiveMode === "json") { - // JSON mode: auto-selected single match, display details - await displayWorkflowGet(selected, repo, options); - } else { - // Interactive mode: show action selection - const workflow = await repo.findByName(selected.name); - const hasInputs = !!(workflow?.inputs && - Object.keys(workflow.inputs).length > 0); - - const action = await renderWorkflowActionSelect( - { - workflowName: selected.name, - workflowDescription: selected.description, - hasInputs, - }, - effectiveMode, - ); - - if (!action) { - ctx.logger.debug`Action selection cancelled`; - return; - } - - if (action === "view") { - await displayWorkflowGet(selected, repo, options); - } else if (action === "run") { - await executeWorkflowFromSearch( - selected, - repo, - runRepo, - repoDir, - options, - ); + const action = renderer.selectedAction(); + + if (action === "run") { + // Shell out to `swamp workflow run `, inheriting stdin/stdout/stderr + // so the user gets the full interactive experience (input file selection, + // progress tree, etc.) + ctx.logger.debug`Running workflow: ${selected.name}`; + const repoDir = options.repoDir ?? "."; + const cmd = new Deno.Command(Deno.execPath(), { + args: ["workflow", "run", selected.name, "--repo-dir", repoDir], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const status = await cmd.output(); + if (!status.success) { + Deno.exit(status.code); } + } else if (effectiveMode === "json") { + // JSON mode: display workflow details + await displayWorkflowGet(selected.name, repo, effectiveMode); } + // Interactive mode without "run": scrollback already has the YAML } else { ctx.logger.debug`Search cancelled`; } diff --git a/src/presentation/renderers/components/help_bar.tsx b/src/presentation/renderers/components/help_bar.tsx new file mode 100644 index 00000000..d5ca2c4f --- /dev/null +++ b/src/presentation/renderers/components/help_bar.tsx @@ -0,0 +1,68 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +// deno-lint-ignore verbatim-module-syntax +import React from "react"; +import { Text } from "ink"; + +/** + * A domain-specific action key binding shown in the help bar. + */ +export interface ActionDef { + /** Single character key (e.g., "i"). */ + key: string; + /** Display label (e.g., "Install"). */ + label: string; + /** Action identifier returned to the caller on activation. */ + action: string; +} + +export interface HelpBarProps { + /** Whether the preview pane is visible (controls whether scroll hint is shown). */ + hasPreview: boolean; + /** Optional domain-specific action keys. */ + actions?: ActionDef[]; +} + +/** + * Keybinding hint bar for the SearchPicker. Shows navigation, preview scroll, + * action keys, and select/cancel hints. Reusable for any interactive Ink component. + */ +export function HelpBar(props: HelpBarProps): React.ReactElement { + const { hasPreview, actions } = props; + + const parts: string[] = [ + "\u2191/\u2193 navigate", + ]; + + if (hasPreview) { + parts.push("Ctrl-u/d scroll preview"); + } + + if (actions && actions.length > 0) { + for (const action of actions) { + parts.push(`${action.key} ${action.label.toLowerCase()}`); + } + } + + parts.push("Enter select"); + parts.push("Esc cancel"); + + return {parts.join(" ")}; +} diff --git a/src/presentation/renderers/components/hooks/mod.ts b/src/presentation/renderers/components/hooks/mod.ts new file mode 100644 index 00000000..dcd37c9a --- /dev/null +++ b/src/presentation/renderers/components/hooks/mod.ts @@ -0,0 +1,36 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +export { + type PreviewFetchResult, + usePreviewFetch, +} from "./use_preview_fetch.ts"; +export { + type PreviewScrollResult, + usePreviewScroll, +} from "./use_preview_scroll.ts"; + +// Re-export shared hooks from the existing output/hooks location for +// co-located access within renderers/components/. +export { + type ScrollMetrics, + useScrollableList, + type UseScrollableListResult, +} from "../../../output/hooks/useScrollableList.ts"; +export { useTerminalSize } from "../../../output/hooks/useTerminalSize.ts"; diff --git a/src/presentation/renderers/components/hooks/use_preview_fetch.ts b/src/presentation/renderers/components/hooks/use_preview_fetch.ts new file mode 100644 index 00000000..992dd755 --- /dev/null +++ b/src/presentation/renderers/components/hooks/use_preview_fetch.ts @@ -0,0 +1,151 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { useEffect, useRef, useState } from "react"; + +/** Default debounce delay before fetching preview detail (milliseconds). */ +const DEFAULT_DEBOUNCE_MS = 100; + +/** Default number of entries to retain in the LRU cache. */ +const DEFAULT_CACHE_SIZE = 10; + +/** + * Simple LRU cache backed by a Map (which preserves insertion order). + * When the cache exceeds `maxSize`, the least-recently-used entry is evicted. + */ +export class LruCache { + private readonly map = new Map(); + private readonly maxSize: number; + + constructor(maxSize: number) { + this.maxSize = maxSize; + } + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value !== undefined) { + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, value); + } + return value; + } + + set(key: K, value: V): void { + // If key already exists, delete first so it moves to the end + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, value); + + // Evict oldest if over capacity + if (this.map.size > this.maxSize) { + const firstKey = this.map.keys().next().value; + if (firstKey !== undefined) { + this.map.delete(firstKey); + } + } + } + + get size(): number { + return this.map.size; + } + + clear(): void { + this.map.clear(); + } +} + +/** + * Return value from usePreviewFetch. + */ +export interface PreviewFetchResult { + /** The fetched detail data, or undefined if not yet loaded. */ + detail: D | undefined; +} + +/** + * React hook that fetches preview detail data with debouncing and LRU caching. + * + * When `item` changes: + * 1. Any in-flight fetch is cancelled (its result is ignored). + * 2. If the item is in the LRU cache, the cached detail is returned immediately. + * 3. Otherwise, after `debounceMs` of stability, `fetchFn(item)` is called. + * 4. The result is cached and returned. + * + * @param item - The currently highlighted item, or undefined if nothing is selected. + * @param fetchFn - Async function to fetch detail data for an item. If undefined, + * no fetching occurs and detail is always undefined. + * @param keyFn - Function to extract a cache key from an item. Defaults to the + * item itself (reference equality). + * @param debounceMs - Milliseconds to wait before fetching. Defaults to 100. + * @param cacheSize - Maximum LRU cache entries. Defaults to 10. + */ +export function usePreviewFetch( + item: T | undefined, + fetchFn: ((item: T) => Promise) | undefined, + keyFn: (item: T) => unknown = (i) => i, + debounceMs: number = DEFAULT_DEBOUNCE_MS, + cacheSize: number = DEFAULT_CACHE_SIZE, +): PreviewFetchResult { + const [detail, setDetail] = useState(undefined); + + // Stable refs to avoid re-creating effects on every render + const cacheRef = useRef(new LruCache(cacheSize)); + const fetchIdRef = useRef(0); + + useEffect(() => { + // Reset detail when item changes + setDetail(undefined); + + if (item === undefined || fetchFn === undefined) { + return; + } + + const key = keyFn(item); + + // Check cache first + const cached = cacheRef.current.get(key); + if (cached !== undefined) { + setDetail(cached); + return; + } + + // Increment fetch ID to invalidate any in-flight fetch + const currentFetchId = ++fetchIdRef.current; + + const timer = setTimeout(() => { + fetchFn(item).then((result) => { + // Only apply if this fetch hasn't been superseded + if (fetchIdRef.current === currentFetchId) { + cacheRef.current.set(key, result); + setDetail(result); + } + }).catch(() => { + // Silently ignore fetch errors — the preview stays at immediate content + }); + }, debounceMs); + + return () => { + clearTimeout(timer); + }; + }, [item, fetchFn, keyFn, debounceMs]); + + return { detail }; +} diff --git a/src/presentation/renderers/components/hooks/use_preview_fetch_test.ts b/src/presentation/renderers/components/hooks/use_preview_fetch_test.ts new file mode 100644 index 00000000..25d6b3fd --- /dev/null +++ b/src/presentation/renderers/components/hooks/use_preview_fetch_test.ts @@ -0,0 +1,101 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals } from "@std/assert"; +import { LruCache } from "./use_preview_fetch.ts"; + +Deno.test("LruCache: get returns undefined for missing key", () => { + const cache = new LruCache(3); + assertEquals(cache.get("a"), undefined); +}); + +Deno.test("LruCache: set and get round-trip", () => { + const cache = new LruCache(3); + cache.set("a", 1); + assertEquals(cache.get("a"), 1); + assertEquals(cache.size, 1); +}); + +Deno.test("LruCache: evicts oldest when over capacity", () => { + const cache = new LruCache(3); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + assertEquals(cache.size, 3); + + // Adding a 4th should evict "a" (oldest) + cache.set("d", 4); + assertEquals(cache.size, 3); + assertEquals(cache.get("a"), undefined); + assertEquals(cache.get("b"), 2); + assertEquals(cache.get("c"), 3); + assertEquals(cache.get("d"), 4); +}); + +Deno.test("LruCache: get moves item to most-recently-used", () => { + const cache = new LruCache(3); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + // Access "a" to make it most-recently-used + cache.get("a"); + + // Adding "d" should evict "b" (now the oldest), not "a" + cache.set("d", 4); + assertEquals(cache.get("a"), 1); + assertEquals(cache.get("b"), undefined); + assertEquals(cache.get("c"), 3); + assertEquals(cache.get("d"), 4); +}); + +Deno.test("LruCache: set overwrites existing key and moves to end", () => { + const cache = new LruCache(3); + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + // Overwrite "a" with new value + cache.set("a", 10); + assertEquals(cache.get("a"), 10); + assertEquals(cache.size, 3); + + // Adding "d" should evict "b" (now oldest), not "a" (just refreshed) + cache.set("d", 4); + assertEquals(cache.get("a"), 10); + assertEquals(cache.get("b"), undefined); +}); + +Deno.test("LruCache: clear empties the cache", () => { + const cache = new LruCache(3); + cache.set("a", 1); + cache.set("b", 2); + cache.clear(); + assertEquals(cache.size, 0); + assertEquals(cache.get("a"), undefined); +}); + +Deno.test("LruCache: size 1 always evicts on new insert", () => { + const cache = new LruCache(1); + cache.set("a", 1); + cache.set("b", 2); + assertEquals(cache.size, 1); + assertEquals(cache.get("a"), undefined); + assertEquals(cache.get("b"), 2); +}); diff --git a/src/presentation/renderers/components/hooks/use_preview_scroll.ts b/src/presentation/renderers/components/hooks/use_preview_scroll.ts new file mode 100644 index 00000000..02c2124e --- /dev/null +++ b/src/presentation/renderers/components/hooks/use_preview_scroll.ts @@ -0,0 +1,76 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { useCallback, useEffect, useState } from "react"; + +/** + * Return value from usePreviewScroll. + */ +export interface PreviewScrollResult { + /** Current scroll offset (lines from top). */ + scrollOffset: number; + /** Scroll up by half a page. */ + scrollUp: () => void; + /** Scroll down by half a page. */ + scrollDown: () => void; +} + +/** + * React hook for managing scroll position within a preview pane. + * + * Provides half-page scrolling (Ctrl-u / Ctrl-d) and automatically resets + * to the top when the content identity changes (tracked via `resetKey`). + * + * @param contentHeight - Total number of lines in the preview content. + * @param viewportHeight - Number of lines visible in the preview pane. + * @param resetKey - When this value changes, scroll resets to 0. Typically + * tied to the selected item identity. + */ +export function usePreviewScroll( + contentHeight: number, + viewportHeight: number, + resetKey: unknown, +): PreviewScrollResult { + const [scrollOffset, setScrollOffset] = useState(0); + + // Reset to top when the selected item changes + useEffect(() => { + setScrollOffset(0); + }, [resetKey]); + + const maxOffset = Math.max(0, contentHeight - viewportHeight); + const halfPage = Math.max(1, Math.floor(viewportHeight / 2)); + + const scrollUp = useCallback(() => { + setScrollOffset((prev) => Math.max(0, prev - halfPage)); + }, [halfPage]); + + const scrollDown = useCallback(() => { + setScrollOffset((prev) => Math.min(maxOffset, prev + halfPage)); + }, [halfPage, maxOffset]); + + // Clamp current offset if content shrinks + const clampedOffset = Math.min(scrollOffset, maxOffset); + + return { + scrollOffset: clampedOffset, + scrollUp, + scrollDown, + }; +} diff --git a/src/presentation/renderers/components/picker_borders.tsx b/src/presentation/renderers/components/picker_borders.tsx new file mode 100644 index 00000000..0a85b5f9 --- /dev/null +++ b/src/presentation/renderers/components/picker_borders.tsx @@ -0,0 +1,267 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +// deno-lint-ignore verbatim-module-syntax +import React from "react"; +import { Box, Text } from "ink"; + +const H = "\u2500"; // ─ +const V = "\u2502"; // │ +const TT = "\u252C"; // ┬ +const BT = "\u2534"; // ┴ + +/** + * Renders a full-height vertical divider line between results and preview. + */ +function VerticalDivider( + props: { height: number }, +): React.ReactElement { + return ( + + {Array.from( + { length: props.height }, + (_, i) => {V}, + )} + + ); +} + +/** + * Top branding line matching the workflow run tree style: + * ─swamp──────────────────── report search ── + */ +export function BrandLine( + props: { width: number; commandName: string }, +): React.ReactElement { + const { width, commandName } = props; + const prefix = H; + const brand = "swamp"; + const suffix = ` ${commandName} ${H}${H}`; + const fixedLen = prefix.length + brand.length + suffix.length; + const padLen = Math.max(0, width - fixedLen); + return ( + + {prefix} + {brand} + {H.repeat(padLen)}{suffix} + + ); +} + +/** + * Horizontal separator spanning full width, optionally with a junction + * character at a specific position for the vertical divider. + */ +function Separator( + props: { width: number; junction?: { position: number; char: string } }, +): React.ReactElement { + const { width, junction } = props; + let line = H.repeat(width); + + if (junction && junction.position >= 0 && junction.position < width) { + const chars = line.split(""); + chars[junction.position] = junction.char; + line = chars.join(""); + } + + return {line}; +} + +export interface BorderedSplitLayoutProps { + /** Total width. */ + width: number; + /** Width of the results pane. */ + resultsWidth: number; + /** Width of the preview pane. */ + previewWidth: number; + /** Height of the content area (results and preview rows). */ + contentHeight: number; + /** Command name shown on the branding line (e.g., "report search"). */ + commandName: string; + /** The search prompt content. */ + promptContent: React.ReactElement; + /** The results list content. */ + resultsContent: React.ReactElement; + /** The preview pane content. */ + previewContent: React.ReactElement; + /** The help bar content. */ + helpContent: React.ReactElement; +} + +/** + * Split layout with clean separator lines: + * + * ─swamp──────────────── report search ── + * > query 8 / 213 + * ───────────────────┬──────────────────── + * results │ preview + * ───────────────────┴──────────────────── + * ↑/↓ navigate Enter select Esc cancel + */ +export function BorderedSplitLayout( + props: BorderedSplitLayoutProps, +): React.ReactElement { + const { + width, + resultsWidth, + contentHeight, + commandName, + promptContent, + resultsContent, + previewContent, + helpContent, + } = props; + + // Position of the vertical divider in the separator lines + const dividerPos = resultsWidth; + + return ( + + {/* Branding line */} + + + {/* Search prompt */} + {promptContent} + + {/* Separator with ┬ junction */} + + + {/* Content area: results | divider | preview */} + + + {resultsContent} + + + + {previewContent} + + + + {/* Separator with ┴ junction */} + + + {/* Help bar */} + {helpContent} + + ); +} + +export interface StackedLayoutProps { + /** Total width. */ + width: number; + /** Height for the results section. */ + resultsHeight: number; + /** Height for the preview section. */ + previewHeight: number; + /** Command name shown on the branding line. */ + commandName: string; + /** The search prompt content. */ + promptContent: React.ReactElement; + /** The results list content. */ + resultsContent: React.ReactElement; + /** The preview pane content. */ + previewContent: React.ReactElement; + /** The help bar content. */ + helpContent: React.ReactElement; +} + +/** + * Stacked layout: results above, preview below, separated by lines. + * + * ─swamp──────────────── report search ── + * > query 8 / 213 + * ──────────────────────────────────────── + * results + * ──────────────────────────────────────── + * preview + * ──────────────────────────────────────── + * ↑/↓ navigate Enter select Esc cancel + */ +export function StackedLayout( + props: StackedLayoutProps, +): React.ReactElement { + const { + width, + resultsHeight, + previewHeight, + commandName, + promptContent, + resultsContent, + previewContent, + helpContent, + } = props; + + return ( + + {/* Branding line */} + + + {/* Search prompt */} + {promptContent} + + {/* Separator */} + + + {/* Results — height constrained */} + + {resultsContent} + + + {/* Separator */} + + + {/* Preview — height constrained */} + + {previewContent} + + + {/* Separator */} + + + {/* Help bar */} + {helpContent} + + ); +} diff --git a/src/presentation/renderers/components/picker_layout.ts b/src/presentation/renderers/components/picker_layout.ts new file mode 100644 index 00000000..23091cf6 --- /dev/null +++ b/src/presentation/renderers/components/picker_layout.ts @@ -0,0 +1,201 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +/** + * Degradation tiers for the SearchPicker, from richest to most compact. + * + * - bordered-split: Side-by-side results + preview with box-drawing borders + * - stacked: Results above, preview below, both bordered + * - inline: Full-width results list with inline detail expansion below selected item + * - minimal: Falls back to existing SearchTUI behavior + */ +export type PickerTier = "bordered-split" | "stacked" | "inline" | "minimal"; + +/** + * Computed layout dimensions for the SearchPicker. Value object — immutable, + * deterministic from terminal dimensions. + */ +export interface PickerLayout { + readonly tier: PickerTier; + /** Width available for the results list (characters). */ + readonly resultsWidth: number; + /** Width available for the preview pane (characters). */ + readonly previewWidth: number; + /** Number of visible result items (rows). */ + readonly resultsHeight: number; + /** Number of lines available for preview content. */ + readonly previewHeight: number; +} + +/** + * Chrome overhead in the bordered-split and stacked tiers: + * - Search bar border top (1) + search row (1) + border mid (1) = 3 + * - Help bar border (1) + help row (1) + border bottom (1) = 3 + * Total chrome = 6 lines + */ +const BORDERED_CHROME_LINES = 6; + +/** + * Chrome overhead in the stacked tier includes an extra border row between + * the results and preview panes. + */ +const STACKED_DIVIDER_LINES = 1; + +/** + * Chrome overhead in the inline tier (no borders): + * - Search bar (1) + count line (1) + help line (1) = 3 + */ +const INLINE_CHROME_LINES = 3; + +/** Lines reserved for inline preview expansion below the selected item. */ +const INLINE_PREVIEW_LINES = 4; + +/** Minimum results visible in any tier (below this, degrade further). */ +const MIN_RESULTS_HEIGHT = 3; + +/** Minimum preview height worth showing. */ +const MIN_PREVIEW_HEIGHT = 3; + +/** + * Fraction of available width allocated to the results pane in bordered-split. + * The remainder goes to preview. + */ +const RESULTS_WIDTH_FRACTION = 0.4; + +/** Maximum width for the results pane (characters). */ +const MAX_RESULTS_WIDTH = 50; + +/** Minimum width for the results pane to be useful. */ +const MIN_RESULTS_WIDTH = 20; + +/** Minimum width for the preview pane to be useful. */ +const MIN_PREVIEW_WIDTH = 30; + +/** + * In bordered-split, 1 column is used for the vertical divider between + * results and preview, plus 2 columns for the left/right outer borders. + */ +const SPLIT_BORDER_COLS = 3; + +/** + * In stacked mode, 2 columns for left/right outer borders. + */ +const STACKED_BORDER_COLS = 2; + +/** + * Fraction of available height allocated to results in stacked mode. + */ +const STACKED_RESULTS_FRACTION = 0.45; + +/** + * Computes the appropriate layout tier and dimensions given terminal size. + * Pure function — no side effects, fully deterministic. + * + * Follows the same pattern as workflow_run_tree/budget.ts: check from + * richest tier to most compact, returning the first that fits. + */ +export function computePickerLayout( + width: number, + height: number, +): PickerLayout { + // Try bordered-split: side-by-side with borders + if (width >= 90 && height >= 16) { + const innerWidth = width - SPLIT_BORDER_COLS; + const resultsWidth = Math.min( + MAX_RESULTS_WIDTH, + Math.max( + MIN_RESULTS_WIDTH, + Math.floor(innerWidth * RESULTS_WIDTH_FRACTION), + ), + ); + const previewWidth = innerWidth - resultsWidth; + + if (previewWidth >= MIN_PREVIEW_WIDTH) { + const contentHeight = height - BORDERED_CHROME_LINES; + const resultsHeight = Math.max(MIN_RESULTS_HEIGHT, contentHeight); + const previewHeight = Math.max(MIN_PREVIEW_HEIGHT, contentHeight); + + return { + tier: "bordered-split", + resultsWidth, + previewWidth, + resultsHeight, + previewHeight, + }; + } + } + + // Try stacked: results above, preview below, both bordered + if (width >= 60 && height >= 24) { + const innerWidth = width - STACKED_BORDER_COLS; + const contentHeight = height - BORDERED_CHROME_LINES - + STACKED_DIVIDER_LINES; + const resultsHeight = Math.max( + MIN_RESULTS_HEIGHT, + Math.floor(contentHeight * STACKED_RESULTS_FRACTION), + ); + const previewHeight = Math.max( + MIN_PREVIEW_HEIGHT, + contentHeight - resultsHeight, + ); + + return { + tier: "stacked", + resultsWidth: innerWidth, + previewWidth: innerWidth, + resultsHeight, + previewHeight, + }; + } + + // Try inline: full-width list with inline expansion + if (width >= 60 && height >= 12) { + const contentHeight = height - INLINE_CHROME_LINES; + const resultsHeight = Math.max( + MIN_RESULTS_HEIGHT, + contentHeight - INLINE_PREVIEW_LINES, + ); + const previewHeight = Math.min( + INLINE_PREVIEW_LINES, + contentHeight - MIN_RESULTS_HEIGHT, + ); + + return { + tier: "inline", + resultsWidth: width, + previewWidth: width - 4, // indented + resultsHeight, + previewHeight: Math.max(0, previewHeight), + }; + } + + // Minimal: falls back to existing SearchTUI behavior + const contentHeight = Math.max( + MIN_RESULTS_HEIGHT, + height - INLINE_CHROME_LINES, + ); + + return { + tier: "minimal", + resultsWidth: width, + previewWidth: 0, + resultsHeight: Math.min(10, contentHeight), + previewHeight: 0, + }; +} diff --git a/src/presentation/renderers/components/picker_layout_test.ts b/src/presentation/renderers/components/picker_layout_test.ts new file mode 100644 index 00000000..8e9e0c85 --- /dev/null +++ b/src/presentation/renderers/components/picker_layout_test.ts @@ -0,0 +1,177 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assertEquals } from "@std/assert"; +import { computePickerLayout } from "./picker_layout.ts"; + +// --- bordered-split tier --- + +Deno.test("computePickerLayout: bordered-split at large terminal", () => { + const layout = computePickerLayout(200, 60); + assertEquals(layout.tier, "bordered-split"); + // Results pane capped at MAX_RESULTS_WIDTH=50 + assertEquals(layout.resultsWidth, 50); + // Preview gets the rest: 200 - 3 (borders) - 50 = 147 + assertEquals(layout.previewWidth, 147); + // Content height: 60 - 6 (chrome) = 54 + assertEquals(layout.resultsHeight, 54); + assertEquals(layout.previewHeight, 54); +}); + +Deno.test("computePickerLayout: bordered-split at 90x16 threshold", () => { + const layout = computePickerLayout(90, 16); + assertEquals(layout.tier, "bordered-split"); + // Inner width: 90 - 3 = 87, results: min(50, floor(87*0.4)) = min(50, 34) = 34 + assertEquals(layout.resultsWidth, 34); + assertEquals(layout.previewWidth, 53); + // Content height: 16 - 6 = 10 + assertEquals(layout.resultsHeight, 10); + assertEquals(layout.previewHeight, 10); +}); + +Deno.test("computePickerLayout: bordered-split respects min results width", () => { + const layout = computePickerLayout(90, 20); + assertEquals(layout.tier, "bordered-split"); + // Results width should be at least MIN_RESULTS_WIDTH=20 + assertEquals(layout.resultsWidth >= 20, true); +}); + +// --- stacked tier --- + +Deno.test("computePickerLayout: stacked at 80x50", () => { + const layout = computePickerLayout(80, 50); + assertEquals(layout.tier, "stacked"); + // Inner width: 80 - 2 = 78 + assertEquals(layout.resultsWidth, 78); + assertEquals(layout.previewWidth, 78); + // Content height: 50 - 6 - 1 = 43, results: floor(43*0.45) = 19 + assertEquals(layout.resultsHeight, 19); + // Preview: 43 - 19 = 24 + assertEquals(layout.previewHeight, 24); +}); + +Deno.test("computePickerLayout: stacked at 60x24 threshold", () => { + const layout = computePickerLayout(60, 24); + assertEquals(layout.tier, "stacked"); + // Content height: 24 - 6 - 1 = 17, results: floor(17*0.45) = 7 + assertEquals(layout.resultsHeight, 7); + // Preview: 17 - 7 = 10 + assertEquals(layout.previewHeight, 10); +}); + +Deno.test("computePickerLayout: narrow but tall falls to stacked not bordered-split", () => { + // 80 wide < 90 threshold, but tall enough for stacked + const layout = computePickerLayout(80, 30); + assertEquals(layout.tier, "stacked"); +}); + +// --- inline tier --- + +Deno.test("computePickerLayout: inline at 80x24", () => { + const layout = computePickerLayout(80, 15); + assertEquals(layout.tier, "inline"); + assertEquals(layout.resultsWidth, 80); + assertEquals(layout.previewWidth, 76); // 80 - 4 indent + // Content height: 15 - 3 = 12, results: 12 - 4 = 8 + assertEquals(layout.resultsHeight, 8); + assertEquals(layout.previewHeight, 4); +}); + +Deno.test("computePickerLayout: inline at threshold 60x12", () => { + const layout = computePickerLayout(60, 12); + assertEquals(layout.tier, "inline"); + // Content height: 12 - 3 = 9, results: max(3, 9-4) = 5 + assertEquals(layout.resultsHeight, 5); + assertEquals(layout.previewHeight, 4); +}); + +// --- minimal tier --- + +Deno.test("computePickerLayout: minimal at 59x12", () => { + // Width below 60 threshold + const layout = computePickerLayout(59, 12); + assertEquals(layout.tier, "minimal"); + assertEquals(layout.previewWidth, 0); + assertEquals(layout.previewHeight, 0); +}); + +Deno.test("computePickerLayout: minimal at 60x11", () => { + // Height below 12 threshold + const layout = computePickerLayout(60, 11); + assertEquals(layout.tier, "minimal"); + assertEquals(layout.previewWidth, 0); + assertEquals(layout.previewHeight, 0); +}); + +Deno.test("computePickerLayout: minimal at tiny terminal", () => { + const layout = computePickerLayout(40, 10); + assertEquals(layout.tier, "minimal"); + assertEquals(layout.resultsWidth, 40); + assertEquals(layout.resultsHeight, 7); // 10 - 3 = 7 + assertEquals(layout.previewWidth, 0); + assertEquals(layout.previewHeight, 0); +}); + +Deno.test("computePickerLayout: minimal caps results at 10", () => { + // Even at a tall narrow terminal, minimal caps results at 10 + const layout = computePickerLayout(40, 40); + assertEquals(layout.tier, "minimal"); + assertEquals(layout.resultsHeight, 10); +}); + +// --- edge cases --- + +Deno.test("computePickerLayout: very small terminal", () => { + const layout = computePickerLayout(20, 5); + assertEquals(layout.tier, "minimal"); + assertEquals(layout.resultsHeight, 3); // MIN_RESULTS_HEIGHT +}); + +Deno.test("computePickerLayout: standard 80x24 falls to stacked", () => { + const layout = computePickerLayout(80, 24); + assertEquals(layout.tier, "stacked"); +}); + +Deno.test("computePickerLayout: results and preview heights are always positive", () => { + for (const w of [20, 40, 60, 80, 100, 200]) { + for (const h of [5, 10, 15, 20, 30, 50]) { + const layout = computePickerLayout(w, h); + assertEquals( + layout.resultsHeight >= 0, + true, + `resultsHeight < 0 at ${w}x${h}`, + ); + assertEquals( + layout.previewHeight >= 0, + true, + `previewHeight < 0 at ${w}x${h}`, + ); + assertEquals( + layout.resultsWidth >= 0, + true, + `resultsWidth < 0 at ${w}x${h}`, + ); + assertEquals( + layout.previewWidth >= 0, + true, + `previewWidth < 0 at ${w}x${h}`, + ); + } + } +}); diff --git a/src/presentation/renderers/components/preview_pane.tsx b/src/presentation/renderers/components/preview_pane.tsx new file mode 100644 index 00000000..fb46505f --- /dev/null +++ b/src/presentation/renderers/components/preview_pane.tsx @@ -0,0 +1,78 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +// deno-lint-ignore verbatim-module-syntax +import React from "react"; +import { Box } from "ink"; + +export interface PreviewPaneProps { + /** The currently highlighted item. */ + item: T | undefined; + /** Fetched detail data (undefined until loaded). */ + detail: D | undefined; + /** Available width for preview content (characters). */ + width: number; + /** Available height for preview content (lines). */ + height: number; + /** Scroll offset (lines from top). Defaults to 0. */ + scrollOffset?: number; + /** Render callback: receives item, optional detail, and dimensions. */ + renderPreview: ( + item: T, + detail: D | undefined, + width: number, + height: number, + ) => React.ReactElement; +} + +/** + * Preview content area that shows detail about the currently highlighted item. + * Height-constrained with overflow hidden to prevent layout overflow. + * + * The rendering is delegated to the `renderPreview` callback, which is + * domain-specific. The PreviewPane handles layout constraints. + */ +export function PreviewPane( + props: PreviewPaneProps, +): React.ReactElement { + const { item, detail, width, height, scrollOffset = 0, renderPreview } = + props; + + if (item === undefined) { + return ; + } + + // Render content taller than the viewport so scrolling has room. + // The outer Box clips via overflow="hidden", the inner Box shifts up + // via negative marginTop. + const renderHeight = height + scrollOffset; + + return ( + + + {renderPreview(item, detail, width, renderHeight)} + + + ); +} diff --git a/src/presentation/renderers/components/results_list.tsx b/src/presentation/renderers/components/results_list.tsx new file mode 100644 index 00000000..42554a23 --- /dev/null +++ b/src/presentation/renderers/components/results_list.tsx @@ -0,0 +1,98 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +// deno-lint-ignore verbatim-module-syntax +import React from "react"; +import { Box, Text } from "ink"; +import type { ScrollMetrics } from "./hooks/mod.ts"; + +const INDENT = " "; + +export interface ResultsListProps { + /** Visible items in the current scroll window. */ + visibleItems: T[]; + /** Index of the selected item in the full list (not the visible window). */ + selectedIndex: number; + /** Scroll metrics for "more above/below" indicators. */ + scrollMetrics: ScrollMetrics; + /** Render callback for a single result line. */ + renderLine: (item: T) => React.ReactElement; + /** Maximum width for each result line (characters). */ + width: number; +} + +/** + * Scrollable single-column result list with reverse-video selection and + * "... N more above/below" indicators. + * + * Reusable for any selection list — not coupled to search. + */ +export function ResultsList( + props: ResultsListProps, +): React.ReactElement { + const { visibleItems, selectedIndex, scrollMetrics, renderLine, width } = + props; + + return ( + + {scrollMetrics.hasMoreAbove && ( + + {INDENT}... {scrollMetrics.moreAboveCount} more above + + )} + {visibleItems.map((item, index) => { + const isSelected = + index + scrollMetrics.moreAboveCount === selectedIndex; + return ( + + {isSelected + ? ( + + {" "} + + {" "} + + ) + : ( + + {INDENT} + + + )} + + ); + })} + {scrollMetrics.hasMoreBelow && ( + + {INDENT}... {scrollMetrics.moreBelowCount} more below + + )} + + ); +} + +/** + * Wrapper to call the generic renderLine callback. This exists as a separate + * component so that the generic `T` flows through correctly. + */ +function ResultLineWrapper( + props: { renderLine: (item: T) => React.ReactElement; item: T }, +): React.ReactElement { + return props.renderLine(props.item); +} diff --git a/src/presentation/renderers/components/search_picker.tsx b/src/presentation/renderers/components/search_picker.tsx new file mode 100644 index 00000000..6eac3c6b --- /dev/null +++ b/src/presentation/renderers/components/search_picker.tsx @@ -0,0 +1,470 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +// deno-lint-ignore verbatim-module-syntax +import React, { useCallback, useMemo, useState } from "react"; +import { Box, render, Text, useApp, useInput } from "ink"; +import { Fzf, type FzfResultItem } from "fzf"; +import { suppressInkTtyErrors } from "../../output/ink_lifecycle.ts"; + +/** Thin cursor character for the search input. */ +const CURSOR = "\u258F"; +import { + usePreviewFetch, + usePreviewScroll, + useScrollableList, + useTerminalSize, +} from "./hooks/mod.ts"; +import { computePickerLayout } from "./picker_layout.ts"; +import type { ActionDef } from "./help_bar.tsx"; +import { HelpBar } from "./help_bar.tsx"; +import { ResultsList } from "./results_list.tsx"; +import { PreviewPane } from "./preview_pane.tsx"; +import { BorderedSplitLayout, StackedLayout } from "./picker_borders.tsx"; + +/** + * Props for the SearchPicker component. + * + * The two type params allow the preview to render richer data (`D`) than the + * search item (`T`) contains. When `fetchPreview` is not provided, `D` defaults + * to `T` and `detail` is always `undefined`. + */ +export interface SearchPickerProps { + items: T[]; + initialQuery: string; + /** Extracts searchable text for fzf matching. */ + selector: (item: T) => string; + /** Renders a single-line summary for the results list. */ + renderResultLine: (item: T) => React.ReactElement; + /** Renders preview content for the highlighted item. */ + renderPreview: ( + item: T, + detail: D | undefined, + width: number, + height: number, + ) => React.ReactElement; + /** Produces plain-text scrollback output on selection. */ + renderScrollback: (item: T, detail: D | undefined) => string; + /** Async function to fetch full detail for preview. Optional. */ + fetchPreview?: (item: T) => Promise; + /** Extracts a stable cache key for the LRU preview cache. */ + previewKeyFn?: (item: T) => unknown; + /** Plural label for the item type (e.g., "models", "data"). */ + itemLabel: string; + /** Command name shown on the branding line (e.g., "model search"). */ + commandName?: string; + /** Hint shown when no results match the query. */ + emptyHint?: (query: string) => React.ReactElement | undefined; + /** Domain-specific action keys. */ + actions?: ActionDef[]; + /** Called when the user selects an item. Receives the scrollback text. */ + onSelect: (item: T, scrollback: string, action?: string) => void; + /** Called when the user cancels. */ + onCancel: () => void; +} + +/** + * Telescope-inspired search picker with three bordered regions: prompt, + * results list, and preview pane. Prompt always has focus — typing always + * filters, arrows always navigate results, Ctrl-u/d scrolls preview. + * + * Gracefully degrades through four tiers based on terminal size: + * bordered-split → stacked → inline → minimal. + */ +export function SearchPicker( + props: SearchPickerProps, +): React.ReactElement { + const { + items, + initialQuery, + selector, + renderResultLine, + renderPreview, + renderScrollback, + fetchPreview, + previewKeyFn, + itemLabel, + commandName, + emptyHint, + actions, + onSelect, + onCancel, + } = props; + const effectiveCommandName = commandName ?? `${itemLabel} search`; + const { exit } = useApp(); + + const [query, setQuery] = useState(initialQuery); + const { width, height } = useTerminalSize(); + const layout = computePickerLayout(width, height); + + // fzf fuzzy matching + const fzf = useMemo(() => { + const FzfCtor = Fzf as unknown as new (...args: unknown[]) => { + find(query: string): FzfResultItem[]; + }; + return new FzfCtor(items, { selector }); + }, [items, selector]); + + const results: FzfResultItem[] = fzf.find(query); + + // Scrollable results list + const { + selectedIndex, + setSelectedIndex, + visibleItems: visibleResults, + scrollMetrics, + } = useScrollableList(results, layout.resultsHeight, [query]); + + // Currently highlighted item + const highlightedItem = results.length > 0 && selectedIndex < results.length + ? results[selectedIndex].item + : undefined; + + // Async preview fetch with debounce + LRU cache + const { detail } = usePreviewFetch( + highlightedItem, + fetchPreview, + previewKeyFn, + ); + + // Estimate actual content height from scrollback text (mirrors preview content). + // This prevents scrolling past the end into empty space. + const scrollbackText = useMemo( + () => highlightedItem ? renderScrollback(highlightedItem, detail) : "", + [highlightedItem, detail, renderScrollback], + ); + const previewContentHeight = Math.max( + layout.previewHeight, + scrollbackText.split("\n").length, + ); + const { scrollOffset, scrollUp, scrollDown } = usePreviewScroll( + previewContentHeight, + layout.previewHeight, + highlightedItem, + ); + + const handleSelect = useCallback( + (action?: string) => { + if (results.length > 0 && selectedIndex < results.length) { + const selected = results[selectedIndex].item; + const scrollbackText = renderScrollback(selected, detail); + exit(); + onSelect(selected, scrollbackText, action); + } + }, + [results, selectedIndex, exit, onSelect, renderScrollback, detail], + ); + + const handleCancel = useCallback(() => { + exit(); + onCancel(); + }, [exit, onCancel]); + + useInput((input, key) => { + if (key.escape || (key.ctrl && input === "c")) { + handleCancel(); + return; + } + + if (key.return) { + handleSelect(); + return; + } + + if (key.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + return; + } + + if (key.downArrow) { + setSelectedIndex((i) => Math.min(results.length - 1, i + 1)); + return; + } + + // Ctrl-u: scroll preview up half page + if (key.ctrl && input === "u") { + scrollUp(); + return; + } + + // Ctrl-d: scroll preview down half page + if (key.ctrl && input === "d") { + scrollDown(); + return; + } + + if (key.backspace || key.delete) { + setQuery((q) => q.slice(0, -1)); + return; + } + + // Check for action keys + if (actions && input && !key.ctrl && !key.meta) { + const action = actions.find((a) => a.key === input); + if (action) { + handleSelect(action.action); + return; + } + } + + // Regular character input + if (input && !key.ctrl && !key.meta) { + setQuery((q) => q + input); + } + }); + + const hasPreview = layout.previewHeight > 0; + + // Shared prompt content — no border wrapping, just search input and count + const promptContent = ( + + + + {">"} + {" "} + + {query} + {CURSOR} + + + {results.length} / {items.length} + + + ); + + // Results content + const resultsContent = results.length === 0 + ? ( + + No matching {itemLabel} found + {query && emptyHint?.(query)} + + ) + : ( + r.item)} + selectedIndex={selectedIndex} + scrollMetrics={scrollMetrics} + renderLine={renderResultLine} + width={layout.resultsWidth} + /> + ); + + // Preview content + const previewContent = hasPreview + ? ( + + ) + : null; + + // Help bar content + const helpContent = ; + + // Render based on tier + if (layout.tier === "bordered-split") { + return ( + } + helpContent={helpContent} + /> + ); + } + + if (layout.tier === "stacked") { + return ( + } + helpContent={helpContent} + /> + ); + } + + if (layout.tier === "inline") { + return ( + + {/* Search input */} + + + Search:{" "} + + {query} + {CURSOR} + + + {/* Results count */} + + + {results.length} / {items.length} {itemLabel} + + + + {/* Results list with inline preview expansion */} + + {resultsContent} + {highlightedItem && previewContent && ( + + {previewContent} + + )} + + + {/* Help text */} + {helpContent} + + ); + } + + // Minimal tier: same layout as existing SearchTUI + return ( + + {/* Search input */} + + + Search:{" "} + + {query} + {CURSOR} + + + {/* Results count */} + + + {results.length} / {items.length} {itemLabel} + + + + {/* Results list */} + + {resultsContent} + + + {/* Help text */} + {helpContent} + + ); +} + +/** + * Result returned from renderInteractivePicker. + */ +export interface PickerResult { + item: T; + action?: string; +} + +/** + * Launches a SearchPicker inside an Ink render context and returns the selected + * item (or `undefined` if the user cancelled). + */ +export async function renderInteractivePicker( + items: T[], + initialQuery: string, + selector: (item: T) => string, + renderResultLine: (item: T) => React.ReactElement, + renderPreview: ( + item: T, + detail: D | undefined, + width: number, + height: number, + ) => React.ReactElement, + renderScrollback: (item: T, detail: D | undefined) => string, + itemLabel: string, + options?: { + fetchPreview?: (item: T) => Promise; + previewKeyFn?: (item: T) => unknown; + commandName?: string; + emptyHint?: (query: string) => React.ReactElement | undefined; + actions?: ActionDef[]; + }, +): Promise | undefined> { + let pendingScrollback: string | undefined; + const isTTY = Deno.stdout.isTerminal(); + + // Enter alternate screen buffer so the picker UI doesn't pollute scrollback. + // When we exit the alternate screen, the terminal restores to its pre-picker + // state — no blank lines, no border remnants. Same technique as fzf/Telescope. + if (isTTY) { + Deno.stdout.writeSync(new TextEncoder().encode("\x1b[?1049h")); + } + + const result = await new Promise | undefined>((resolve) => { + const cleanupTty = suppressInkTtyErrors(); + const { waitUntilExit } = render( + { + cleanupTty(); + // Only print scrollback for default select (Enter), not action keys + if (!action) { + pendingScrollback = scrollback; + } + resolve({ item, action }); + }} + onCancel={() => { + cleanupTty(); + resolve(undefined); + }} + />, + ); + waitUntilExit().catch(() => {}); + }); + + // Exit alternate screen buffer — terminal restores to pre-picker state + if (isTTY) { + Deno.stdout.writeSync(new TextEncoder().encode("\x1b[?1049l")); + } + + // Print scrollback on the normal screen + if (pendingScrollback) { + console.log(pendingScrollback); + } + + return result; +} diff --git a/src/presentation/renderers/data_search.tsx b/src/presentation/renderers/data_search.tsx index 094e7260..6d30bcd9 100644 --- a/src/presentation/renderers/data_search.tsx +++ b/src/presentation/renderers/data_search.tsx @@ -28,7 +28,24 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; +import { renderMarkdownToTerminal } from "../markdown_renderer.ts"; + +/** + * Detail data fetched for the preview pane. Contains the raw content string + * (or a description for binary data). + */ +export interface DataPreviewDetail { + content: string | undefined; + contentPath: string; +} + +/** + * Callback type for fetching data content for the preview pane. + */ +export type DataPreviewFetcher = ( + item: DataSearchItem, +) => Promise; /** * Formats a byte count into a human-readable size string. @@ -55,17 +72,6 @@ function formatRelativeTime(isoStr: string): string { return `${days}d ago`; } -/** - * Returns the color for a lifetime label. - */ -function getLifetimeColor( - lifetime: string, -): string | undefined { - if (lifetime === "infinite") return "green"; - if (lifetime === "ephemeral") return "yellow"; - return undefined; -} - export type DataSearchRenderer = SearchRenderer< DataSearchEvent, DataSearchItem @@ -93,6 +99,11 @@ class JsonDataSearchRenderer implements DataSearchRenderer { class InkDataSearchRenderer implements DataSearchRenderer { private _selected: DataSearchItem | undefined; + private readonly fetchPreview: DataPreviewFetcher | undefined; + + constructor(fetchPreview?: DataPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): DataSearchItem | undefined { return this._selected; @@ -102,7 +113,10 @@ class InkDataSearchRenderer implements DataSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + DataSearchItem, + DataPreviewDetail + >( e.data.results, e.data.query, (item) => @@ -111,40 +125,18 @@ class InkDataSearchRenderer implements DataSearchRenderer { } ${item.stepTag ?? ""} ${ Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(" ") }`, - (item, isSelected) => ( - - - - {isSelected ? "> " : " "} - {item.name} - - v{item.version} - {item.modelName} - {item.contentType} - - {` [${item.lifetime}]`} - - {formatSize(item.size)} - {formatRelativeTime(item.createdAt)} - - {isSelected && ( - - type: {item.type} - ownerType: {item.ownerType} - ownerRef: {item.ownerRef} - {item.workflowTag && ( - workflow: {item.workflowTag} - )} - {item.stepTag && step: {item.stepTag}} - - )} - - ), + renderDataResultLine, + renderDataPreview, + renderDataScrollback, "data", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => `${item.id}:${item.version}`, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -155,11 +147,126 @@ class InkDataSearchRenderer implements DataSearchRenderer { export function createDataSearchRenderer( mode: OutputMode, + fetchPreview?: DataPreviewFetcher, ): DataSearchRenderer { switch (mode) { case "json": return new JsonDataSearchRenderer(); case "log": - return new InkDataSearchRenderer(); + return new InkDataSearchRenderer(fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** + * Builds the metadata section as markdown (rendered separately from content + * to avoid markdown-in-markdown nesting issues). + */ +function buildMetadataMarkdown(item: DataSearchItem): string { + const tagEntries = Object.entries(item.tags); + const lines: string[] = [ + `**${item.name}** v${item.version}`, + "", + `**Model:** ${item.modelName} (${item.modelType})`, + `**Content Type:** ${item.contentType}`, + `**Size:** ${formatSize(item.size)}`, + `**Lifetime:** ${item.lifetime}`, + `**Type:** ${item.type}`, + `**Owner:** ${item.ownerType} ${item.ownerRef}`, + ]; + + if (item.workflowTag) lines.push(`**Workflow:** ${item.workflowTag}`); + if (item.stepTag) lines.push(`**Step:** ${item.stepTag}`); + + if (tagEntries.length > 0) { + lines.push( + `**Tags:** ${tagEntries.map(([k, v]) => `${k}=${v}`).join(", ")}`, + ); + } + + lines.push(`**Created:** ${formatRelativeTime(item.createdAt)}`); + + return lines.join("\n"); +} + +/** + * Renders content based on its type. Markdown content is rendered as markdown. + * JSON/YAML get syntax-highlighted code blocks. Other text is shown plain. + */ +function renderContentString( + content: string, + contentType: string, +): string { + if (contentType === "text/markdown") { + // Content IS markdown — render it directly + return renderMarkdownToTerminal(content); + } + + if (contentType === "application/json") { + return renderMarkdownToTerminal("```json\n" + content + "\n```"); + } + + if ( + contentType === "application/yaml" || contentType === "application/x-yaml" + ) { + return renderMarkdownToTerminal("```yaml\n" + content + "\n```"); + } + + // Plain text or unknown — show as-is + return content; +} + +function renderDataResultLine(item: DataSearchItem): React.ReactElement { + return ( + + {`${item.name} `} + + {`${item.contentType} ${formatSize(item.size)} ${ + formatRelativeTime(item.createdAt) + }`} + + + ); +} + +function renderDataPreview( + item: DataSearchItem, + detail: DataPreviewDetail | undefined, + _width: number, + _height: number, +): React.ReactElement { + // Combine metadata + content into a single string to avoid Ink layout + // issues with multiple blocks containing ANSI-formatted content. + const parts: string[] = [ + renderMarkdownToTerminal(buildMetadataMarkdown(item)), + ]; + + if (detail && detail.content) { + parts.push(renderContentString(detail.content, item.contentType)); + } else if (detail && !detail.content) { + parts.push(`(binary data at ${detail.contentPath})`); + } + + return ( + + {parts.join("\n")} + + ); +} + +function renderDataScrollback( + item: DataSearchItem, + detail: DataPreviewDetail | undefined, +): string { + const metadata = renderMarkdownToTerminal(buildMetadataMarkdown(item)); + + if (detail?.content) { + const content = renderContentString(detail.content, item.contentType); + return metadata + "\n" + content; + } + + return metadata; +} diff --git a/src/presentation/renderers/extension_search.tsx b/src/presentation/renderers/extension_search.tsx index d5907684..b47e0e11 100644 --- a/src/presentation/renderers/extension_search.tsx +++ b/src/presentation/renderers/extension_search.tsx @@ -19,7 +19,7 @@ // deno-lint-ignore verbatim-module-syntax import React from "react"; -import { Box, render, Text, useApp, useInput } from "ink"; +import { Box, Text } from "ink"; import type { EventHandlers, ExtensionSearchEvent, @@ -28,8 +28,9 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; -import { suppressInkTtyErrors } from "../output/ink_lifecycle.ts"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; + +const EM_DASH = "\u2014"; export interface ExtensionSearchRenderer extends SearchRenderer { @@ -66,204 +67,166 @@ class JsonExtensionSearchRenderer implements ExtensionSearchRenderer { } } -/** - * Extension detail view with action selection (Enter: Select, i: Install, Esc: Back). - * Matches the pre-migration UX where selecting an extension shows details - * before committing to an action. - */ -interface ExtensionDetailViewProps { - extension: ExtensionSearchItem; - onAction: (action: "select" | "install") => void; - onBack: () => void; +class InkExtensionSearchRenderer implements ExtensionSearchRenderer { + private _selected: ExtensionSearchItem | undefined; + private _action: "select" | "install" | undefined; + + selectedItem(): ExtensionSearchItem | undefined { + return this._selected; + } + + selectedAction(): "select" | "install" | undefined { + return this._action; + } + + handlers(): EventHandlers { + return { + resolving: () => {}, + completed: async (e) => { + const result = await renderInteractivePicker( + e.data.results, + e.data.query, + (item) => `${item.name} ${item.description} ${item.labels.join(" ")}`, + renderExtensionResultLine, + renderExtensionPreview, + renderExtensionScrollback, + "extensions", + { + previewKeyFn: (item) => item.name, + actions: [ + { key: "i", label: "Install", action: "install" }, + ], + }, + ); + + if (result) { + this._selected = result.item; + this._action = (result.action as "select" | "install") ?? "select"; + } + }, + error: (e) => { + throw new UserError(e.error.message); + }, + }; + } +} + +export function createExtensionSearchRenderer( + mode: OutputMode, +): ExtensionSearchRenderer { + switch (mode) { + case "json": + return new JsonExtensionSearchRenderer(); + case "log": + return new InkExtensionSearchRenderer(); + } } -function ExtensionDetailView( - props: ExtensionDetailViewProps, +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +const DESCRIPTION_MAX = 60; + +function renderExtensionResultLine( + item: ExtensionSearchItem, ): React.ReactElement { - const { extension, onAction, onBack } = props; - const { exit } = useApp(); + const truncatedDesc = item.description.length > DESCRIPTION_MAX + ? item.description.slice(0, DESCRIPTION_MAX) + "\u2026" + : item.description; - useInput((input, key) => { - if (key.escape || (key.ctrl && input === "c")) { - onBack(); - return; - } - if (key.return) { - exit(); - onAction("select"); - return; - } - if (input === "i" && !key.ctrl && !key.meta) { - exit(); - onAction("install"); - return; - } - }); + const labelsStr = item.labels.length > 0 + ? ` [${item.labels.join(", ")}]` + : ""; + return ( + + {`${item.name} `} + {`v${item.latestVersion}`} + {truncatedDesc && {` ${EM_DASH} ${truncatedDesc}`}} + {labelsStr && {labelsStr}} + + ); +} +function renderExtensionPreview( + item: ExtensionSearchItem, + _detail: ExtensionSearchItem | undefined, + _width: number, + _height: number, +): React.ReactElement { return ( - + - {extension.name} - {` v${extension.latestVersion}`} + {item.name} + v{item.latestVersion} - {extension.description && ( + {item.description && ( - {extension.description} + {item.description} )} - {extension.platforms.length > 0 && ( + {item.platforms.length > 0 && ( - {`Platforms: ${extension.platforms.join(", ")}`} + + Platforms: + {item.platforms.join(", ")} + )} - {extension.labels.length > 0 && ( + {item.labels.length > 0 && ( - {`Labels: ${extension.labels.join(", ")}`} + + Labels: + {item.labels.join(", ")} + )} - {extension.contentTypes.length > 0 && ( + {item.contentTypes.length > 0 && ( - - {`Content Types: ${extension.contentTypes.join(", ")}`} + + Content Types: + {item.contentTypes.join(", ")} )} - {`Created: ${extension.createdAt}`} + Created: {item.createdAt} - - {`Updated: ${extension.updatedAt}`} - - - - Enter: Select | i: Install | Esc: Back + Updated: {item.updatedAt} ); } -/** - * Two-phase detail view: shows extension details and lets the user choose - * "select" (Enter) or "install" (i), or go back (Esc). - * - * When the user goes back, returns `undefined` so the caller can re-launch the - * search TUI. - */ -function renderExtensionAction( - extension: ExtensionSearchItem, -): Promise<"select" | "install" | undefined> { - return new Promise<"select" | "install" | undefined>((resolve) => { - const cleanupTty = suppressInkTtyErrors(); - const { waitUntilExit, unmount } = render( - { - cleanupTty(); - resolve(action); - }} - onBack={() => { - cleanupTty(); - unmount(); - resolve(undefined); - }} - />, - ); - waitUntilExit().catch(() => {}); - }); -} - -class InkExtensionSearchRenderer implements ExtensionSearchRenderer { - private _selected: ExtensionSearchItem | undefined; - private _action: "select" | "install" | undefined; +function renderExtensionScrollback( + item: ExtensionSearchItem, + _detail: ExtensionSearchItem | undefined, +): string { + const lines: string[] = [ + `${item.name} v${item.latestVersion}`, + ]; - selectedItem(): ExtensionSearchItem | undefined { - return this._selected; + if (item.description) { + lines.push(item.description); } - selectedAction(): "select" | "install" | undefined { - return this._action; + if (item.platforms.length > 0) { + lines.push(`Platforms: ${item.platforms.join(", ")}`); } - handlers(): EventHandlers { - return { - resolving: () => {}, - completed: async (e) => { - // Loop: search → detail → back returns to search - while (true) { - const selected = await renderInteractiveSearch( - e.data.results, - e.data.query, - (item) => - `${item.name} ${item.description} ${item.labels.join(" ")}`, - (item, isSelected) => { - const descriptionMax = 60; - const truncatedDesc = item.description.length > descriptionMax - ? item.description.slice(0, descriptionMax) + "\u2026" - : item.description; - - return ( - - - {isSelected ? "\u25B6 " : " "} - {item.name} - - - {` v${item.latestVersion}`} - - {truncatedDesc && ( - - {` \u2014 ${truncatedDesc}`} - - )} - {item.labels.length > 0 && ( - - {` [${item.labels.join(", ")}]`} - - )} - - ); - }, - "extensions", - ); - - if (!selected) { - // User cancelled the search - return; - } - - // Show detail view with action selection - const action = await renderExtensionAction(selected); - if (action) { - this._selected = selected; - this._action = action; - return; - } - // action === undefined means "back" — loop to re-show search - } - }, - error: (e) => { - throw new UserError(e.error.message); - }, - }; + if (item.labels.length > 0) { + lines.push(`Labels: ${item.labels.join(", ")}`); } -} -export function createExtensionSearchRenderer( - mode: OutputMode, -): ExtensionSearchRenderer { - switch (mode) { - case "json": - return new JsonExtensionSearchRenderer(); - case "log": - return new InkExtensionSearchRenderer(); + if (item.contentTypes.length > 0) { + lines.push(`Content Types: ${item.contentTypes.join(", ")}`); } + + return lines.join("\n"); } diff --git a/src/presentation/renderers/model_output_search.tsx b/src/presentation/renderers/model_output_search.tsx index 8f7e26b8..1d1e5e53 100644 --- a/src/presentation/renderers/model_output_search.tsx +++ b/src/presentation/renderers/model_output_search.tsx @@ -22,6 +22,7 @@ import React from "react"; import { Box, Text } from "ink"; import type { EventHandlers, + ModelOutputGetData, ModelOutputSearchData, ModelOutputSearchEvent, ModelOutputSearchItem, @@ -29,7 +30,7 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; /** * Filters outputs by a query string. @@ -76,6 +77,13 @@ function formatDuration(ms: number): string { return `${minutes}m ${seconds}s`; } +/** + * Callback type for fetching output detail data for the preview pane. + */ +export type OutputPreviewFetcher = ( + item: ModelOutputSearchItem, +) => Promise; + export type ModelOutputSearchRenderer = SearchRenderer< ModelOutputSearchEvent, ModelOutputSearchItem @@ -108,6 +116,11 @@ class JsonModelOutputSearchRenderer implements ModelOutputSearchRenderer { class InkModelOutputSearchRenderer implements ModelOutputSearchRenderer { private _selected: ModelOutputSearchItem | undefined; + private readonly fetchPreview: OutputPreviewFetcher | undefined; + + constructor(fetchPreview?: OutputPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): ModelOutputSearchItem | undefined { return this._selected; @@ -117,35 +130,28 @@ class InkModelOutputSearchRenderer implements ModelOutputSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + ModelOutputSearchItem, + ModelOutputGetData + >( e.data.results, e.data.query, (item) => `${ item.modelName ?? item.definitionId } ${item.type} ${item.methodName} ${item.status} ${item.id}`, - (item, isSelected) => ( - - - {isSelected ? "> " : " "} - {item.modelName ?? item.definitionId.slice(0, 8)} - - {` ${item.methodName}`} - - {` [${item.status}]`} - - {item.durationMs !== undefined && ( - - {` (${formatDuration(item.durationMs)})`} - - )} - - ), + renderOutputResultLine, + renderOutputPreview, + renderOutputScrollback, "outputs", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.id, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -156,11 +162,188 @@ class InkModelOutputSearchRenderer implements ModelOutputSearchRenderer { export function createModelOutputSearchRenderer( mode: OutputMode, + fetchPreview?: OutputPreviewFetcher, ): ModelOutputSearchRenderer { switch (mode) { case "json": return new JsonModelOutputSearchRenderer(); case "log": - return new InkModelOutputSearchRenderer(); + return new InkModelOutputSearchRenderer(fetchPreview); + } +} + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderOutputResultLine( + item: ModelOutputSearchItem, +): React.ReactElement { + const methodLabel = ` ${item.methodName}`; + const statusLabel = ` [${item.status}]`; + const durationLabel = item.durationMs !== undefined + ? ` (${formatDuration(item.durationMs)})` + : undefined; + return ( + + {item.modelName ?? item.definitionId.slice(0, 8)} + {methodLabel} + {statusLabel} + {durationLabel !== undefined && {durationLabel}} + + ); +} + +/** Renders preview content for a model output. */ +function renderOutputPreview( + item: ModelOutputSearchItem, + detail: ModelOutputGetData | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + const displayName = item.modelName ?? item.definitionId; + return ( + + {displayName} + type: {item.type} + + method: {item.methodName} + + + status: {item.status} + + started: {item.startedAt} + {item.durationMs !== undefined && ( + duration: {formatDuration(item.durationMs)} + )} + definitionId: {item.definitionId} + + ); } + + // Full detail from fetchPreview + const displayName = detail.modelName ?? detail.definitionId; + return ( + + {displayName} + type: {detail.type} + + method: {detail.methodName} + + + status:{" "} + {detail.status} + + started: {detail.startedAt} + {detail.completedAt && ( + completed: {detail.completedAt} + )} + {detail.durationMs !== undefined && ( + duration: {formatDuration(detail.durationMs)} + )} + retries: {detail.retryCount} + + + Provenance: + triggeredBy: {detail.provenance.triggeredBy} + modelVersion: {detail.provenance.modelVersion} + + definitionHash: {detail.provenance.definitionHash} + + {detail.provenance.workflowId && ( + workflow: {detail.provenance.workflowId} + )} + {detail.provenance.stepName && ( + step: {detail.provenance.stepName} + )} + + + {detail.artifacts && + detail.artifacts.dataArtifacts.length > 0 && ( + + Artifacts: + {detail.artifacts.dataArtifacts.map((a) => ( + + {" "} + {a.name} v{a.version} + + ))} + + )} + + {detail.error && ( + + Error: + {detail.error.message} + + )} + + ); +} + +/** Produces plain-text scrollback output for a selected model output. */ +function renderOutputScrollback( + item: ModelOutputSearchItem, + detail: ModelOutputGetData | undefined, +): string { + if (!detail) { + const displayName = item.modelName ?? item.definitionId; + const lines: string[] = [ + `${displayName} - ${item.methodName} [${item.status}]`, + `type: ${item.type}`, + `started: ${item.startedAt}`, + ]; + + if (item.durationMs !== undefined) { + lines.push(`duration: ${formatDuration(item.durationMs)}`); + } + + lines.push(`definitionId: ${item.definitionId}`); + + return lines.join("\n"); + } + + const displayName = detail.modelName ?? detail.definitionId; + const lines: string[] = [ + `${displayName} - ${detail.methodName} [${detail.status}]`, + `type: ${detail.type}`, + `started: ${detail.startedAt}`, + ]; + + if (detail.completedAt) { + lines.push(`completed: ${detail.completedAt}`); + } + if (detail.durationMs !== undefined) { + lines.push(`duration: ${formatDuration(detail.durationMs)}`); + } + lines.push(`retries: ${detail.retryCount}`); + + lines.push(""); + lines.push("Provenance:"); + lines.push(` triggeredBy: ${detail.provenance.triggeredBy}`); + lines.push(` modelVersion: ${detail.provenance.modelVersion}`); + if (detail.provenance.workflowId) { + lines.push(` workflow: ${detail.provenance.workflowId}`); + } + if (detail.provenance.stepName) { + lines.push(` step: ${detail.provenance.stepName}`); + } + + if (detail.artifacts && detail.artifacts.dataArtifacts.length > 0) { + lines.push(""); + lines.push("Artifacts:"); + for (const a of detail.artifacts.dataArtifacts) { + lines.push(` ${a.name} v${a.version}`); + } + } + + if (detail.error) { + lines.push(""); + lines.push(`Error: ${detail.error.message}`); + } + + return lines.join("\n"); } diff --git a/src/presentation/renderers/model_search.tsx b/src/presentation/renderers/model_search.tsx index 326c62d0..3c6c0c9f 100644 --- a/src/presentation/renderers/model_search.tsx +++ b/src/presentation/renderers/model_search.tsx @@ -22,6 +22,7 @@ import React from "react"; import { Box, Text } from "ink"; import type { EventHandlers, + ModelGetData, ModelSearchData, ModelSearchEvent, ModelSearchItem, @@ -29,7 +30,14 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { + type PickerResult, + renderInteractivePicker, +} from "./components/search_picker.tsx"; +import { formatMethodLines, formatSchemaAttributes } from "./model_get.ts"; + +const INDENT_2 = " "; +const INDENT_4 = " "; /** * Filters models by a query string (case-insensitive match on name, type, or id). @@ -48,6 +56,13 @@ export function filterModels( ); } +/** + * Callback type for fetching model detail data for the preview pane. + */ +export type ModelPreviewFetcher = ( + item: ModelSearchItem, +) => Promise; + export type ModelSearchRenderer = SearchRenderer< ModelSearchEvent, ModelSearchItem @@ -85,6 +100,11 @@ class JsonModelSearchRenderer implements ModelSearchRenderer { class InkModelSearchRenderer implements ModelSearchRenderer { private _selected: ModelSearchItem | undefined; + private readonly fetchPreview: ModelPreviewFetcher | undefined; + + constructor(fetchPreview?: ModelPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): ModelSearchItem | undefined { return this._selected; @@ -94,24 +114,25 @@ class InkModelSearchRenderer implements ModelSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + ModelSearchItem, + ModelGetData + >( e.data.results, e.data.query, (item) => `${item.name} ${item.type} ${item.id}`, - (item, isSelected) => ( - - - {isSelected ? "> " : " "} - {item.name} - - ({item.type}) - - ), + renderModelResultLine, + renderModelPreview, + renderModelScrollback, "models", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.id, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -120,13 +141,162 @@ class InkModelSearchRenderer implements ModelSearchRenderer { } } +/** + * Creates a model search renderer for the given output mode. + * + * @param mode - Output mode (log for interactive, json for machine-readable). + * @param fetchPreview - Optional callback to fetch model detail for the preview + * pane. When provided, the picker shows full method/argument detail. When + * omitted, only the model name and type are shown in the preview. + */ export function createModelSearchRenderer( mode: OutputMode, + fetchPreview?: ModelPreviewFetcher, ): ModelSearchRenderer { switch (mode) { case "json": return new JsonModelSearchRenderer(); case "log": - return new InkModelSearchRenderer(); + return new InkModelSearchRenderer(fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderModelResultLine(item: ModelSearchItem): React.ReactElement { + return ( + + {item.name} ({item.type}) + + ); +} + +/** + * Renders preview content for a model. Shows immediate metadata from the search + * item, and when detail is available, shows methods with argument signatures. + */ +function renderModelPreview( + item: ModelSearchItem, + detail: ModelGetData | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + return ( + + {item.name} + type: {item.type} + + ); + } + + // Full detail from fetchPreview + return ( + + {detail.name} + type: {detail.type} + version: {detail.version} + {detail.methods && detail.methods.length > 0 && ( + + Methods: + {detail.methods.map((method) => ( + + + {method.name} + - {method.description} + + {renderMethodArguments(method.arguments)} + {method.dataOutputSpecs && method.dataOutputSpecs.length > 0 && ( + + Data Outputs: + {method.dataOutputSpecs.map((spec) => ( + + {INDENT_2} + {spec.specName} [{spec.kind}] + {spec.description ? ` - ${spec.description}` : ""} + + ))} + + )} + + ))} + + )} + + ); +} + +/** Renders JSON Schema arguments as Ink elements for the preview pane. */ +function renderMethodArguments(schema: object): React.ReactElement | null { + const s = schema as { + properties?: Record< + string, + { type?: string; enum?: string[]; description?: string } + >; + required?: string[]; + }; + if (!s.properties) return null; + + const required = new Set(s.required ?? []); + const entries = Object.entries(s.properties); + if (entries.length === 0) return null; + + return ( + + Arguments: + {entries.map(([name, prop]) => ( + + {INDENT_4} + {name} + {prop.type ? ({prop.type}) : null} + {prop.enum ? [{prop.enum.join(", ")}] : null} + {required.has(name) ? *required : null} + + ))} + + ); +} + +/** + * Produces plain-text scrollback output for a selected model. + * Reuses the same formatMethodLines/formatSchemaAttributes from model_get.ts. + */ +function renderModelScrollback( + item: ModelSearchItem, + detail: ModelGetData | undefined, +): string { + if (!detail) { + return `${item.name} (${item.type})`; + } + + const lines: string[] = [ + `${detail.name} (${detail.type}) v${detail.version}`, + ]; + + if (detail.methods && detail.methods.length > 0) { + lines.push(""); + lines.push("Methods:"); + lines.push(...formatMethodLines(detail.methods)); + } + + if (detail.globalArgumentsSchema) { + const schemaAttrs = formatSchemaAttributes( + detail.globalArgumentsSchema, + " ", + ); + if (schemaAttrs.length > 0) { + lines.push(""); + lines.push("Global Arguments Schema:"); + lines.push(...schemaAttrs); + } + } + + return lines.join("\n"); +} + +// Re-export PickerResult for use by the command handler +export type { PickerResult }; diff --git a/src/presentation/renderers/report_search.tsx b/src/presentation/renderers/report_search.tsx index 56c36105..53fe855e 100644 --- a/src/presentation/renderers/report_search.tsx +++ b/src/presentation/renderers/report_search.tsx @@ -23,12 +23,14 @@ import { Box, Text } from "ink"; import type { EventHandlers, ReportSearchEvent, + StoredReportDetail, StoredReportSummary, } from "../../libswamp/mod.ts"; import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; +import { renderMarkdownToTerminal } from "../markdown_renderer.ts"; /** * Formats an ISO date string as a relative time (e.g., "2d ago"). @@ -64,6 +66,13 @@ function filterReports( ); } +/** + * Callback type for fetching report detail data for the preview pane. + */ +export type ReportPreviewFetcher = ( + item: StoredReportSummary, +) => Promise; + export type ReportSearchRenderer = SearchRenderer< ReportSearchEvent, StoredReportSummary @@ -109,9 +118,11 @@ class JsonReportSearchRenderer implements ReportSearchRenderer { class InkReportSearchRenderer implements ReportSearchRenderer { private _selected: StoredReportSummary | undefined; private query: string; + private readonly fetchPreview: ReportPreviewFetcher | undefined; - constructor(query: string) { + constructor(query: string, fetchPreview?: ReportPreviewFetcher) { this.query = query; + this.fetchPreview = fetchPreview; } selectedItem(): StoredReportSummary | undefined { @@ -122,45 +133,29 @@ class InkReportSearchRenderer implements ReportSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + StoredReportSummary, + StoredReportDetail + >( e.data.reports, this.query, (item) => `${item.reportName} ${item.modelName} ${item.reportScope} ${ item.workflowName ?? "" } ${item.varySuffix ?? ""}`, - (item, isSelected) => { - const source = item.workflowName ?? item.modelName; - return ( - - - - {isSelected ? "> " : " "} - {item.reportName} - - {source} - {item.reportScope} - {item.varySuffix && ( - [{item.varySuffix}] - )} - v{item.version} - {formatRelativeTime(item.createdAt)} - - {isSelected && ( - - - type: {item.modelType} | id: {item.modelId} - - - )} - - ); - }, + renderReportResultLine, + renderReportPreview, + renderReportScrollback, "reports", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => + `${item.reportName}-${item.modelId}-${item.version}`, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -172,11 +167,109 @@ class InkReportSearchRenderer implements ReportSearchRenderer { export function createReportSearchRenderer( mode: OutputMode, query: string, + fetchPreview?: ReportPreviewFetcher, ): ReportSearchRenderer { switch (mode) { case "json": return new JsonReportSearchRenderer(query); case "log": - return new InkReportSearchRenderer(query); + return new InkReportSearchRenderer(query, fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderReportResultLine( + item: StoredReportSummary, +): React.ReactElement { + const source = item.workflowName ?? item.modelName; + return ( + + {`${item.reportName} `} + {source} + {` ${item.reportScope}`} + {item.varySuffix + ? {` [${item.varySuffix}]`} + : null} + + {` v${item.version} ${formatRelativeTime(item.createdAt)}`} + + + ); +} + +/** Preview content for a report. */ +function renderReportPreview( + item: StoredReportSummary, + detail: StoredReportDetail | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + return ( + + {item.reportName} + scope: {item.reportScope} + model: {item.modelName} + type: {item.modelType} + {item.workflowName && ( + workflow: {item.workflowName} + )} + version: {item.version} + created: {item.createdAt} + {item.varySuffix && variant: {item.varySuffix}} + + ); + } + + // Combine header + rendered markdown into a single string to avoid + // Ink layout overlap with multiple ANSI-formatted blocks. + const header = + `${detail.reportName}\nscope: ${detail.reportScope} | model: ${detail.modelName} | v${detail.version}\n`; + const rendered = renderMarkdownToTerminal(detail.markdown); + return ( + + {header + rendered} + + ); +} + +/** Plain-text scrollback output for a selected report. */ +function renderReportScrollback( + item: StoredReportSummary, + detail: StoredReportDetail | undefined, +): string { + if (!detail) { + const lines: string[] = [ + item.reportName, + `scope: ${item.reportScope}`, + `model: ${item.modelName} (${item.modelType})`, + ]; + + if (item.workflowName) { + lines.push(`workflow: ${item.workflowName}`); + } + + lines.push(`version: ${item.version}`); + lines.push(`created: ${item.createdAt}`); + + if (item.varySuffix) { + lines.push(`variant: ${item.varySuffix}`); + } + + return lines.join("\n"); + } + + // With detail, show the rendered markdown + const lines: string[] = [ + `${detail.reportName} (${detail.reportScope}) v${detail.version}`, + "", + renderMarkdownToTerminal(detail.markdown), + ]; + + return lines.join("\n"); +} diff --git a/src/presentation/renderers/type_search.tsx b/src/presentation/renderers/type_search.tsx index ce7e4a1c..a533d21d 100644 --- a/src/presentation/renderers/type_search.tsx +++ b/src/presentation/renderers/type_search.tsx @@ -22,6 +22,7 @@ import React from "react"; import { Box, Text } from "ink"; import type { EventHandlers, + TypeDescribeData, TypeSearchData, TypeSearchEvent, TypeSearchItem, @@ -29,7 +30,10 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; +import { formatMethodLines } from "./model_get.ts"; + +const INDENT_4 = " "; /** * Filters types by a query string (case-insensitive match on raw or normalized). @@ -47,6 +51,13 @@ function filterTypes( ); } +/** + * Callback type for fetching type detail data for the preview pane. + */ +export type TypePreviewFetcher = ( + item: TypeSearchItem, +) => Promise; + export type TypeSearchRenderer = SearchRenderer< TypeSearchEvent, TypeSearchItem @@ -83,6 +94,11 @@ class JsonTypeSearchRenderer implements TypeSearchRenderer { class InkTypeSearchRenderer implements TypeSearchRenderer { private _selected: TypeSearchItem | undefined; + private readonly fetchPreview: TypePreviewFetcher | undefined; + + constructor(fetchPreview?: TypePreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): TypeSearchItem | undefined { return this._selected; @@ -92,32 +108,31 @@ class InkTypeSearchRenderer implements TypeSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + TypeSearchItem, + TypeDescribeData + >( e.data.results, e.data.query, (item) => `${item.raw} ${item.normalized}`, - (item, isSelected) => ( - - - {isSelected ? "\u25B6 " : " "} - {item.normalized} - - {item.raw !== item.normalized && ( - ({item.raw}) - )} - - ), + renderTypeResultLine, + renderTypePreview, + renderTypeScrollback, "types", - (query) => ( - - Tip: run `swamp extension search{" "} - {query}` to check community extensions - - ), + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.raw, + emptyHint: (query) => ( + + Tip: run `swamp extension search{" "} + {query}` to check community extensions + + ), + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -128,11 +143,130 @@ class InkTypeSearchRenderer implements TypeSearchRenderer { export function createTypeSearchRenderer( mode: OutputMode, + fetchPreview?: TypePreviewFetcher, ): TypeSearchRenderer { switch (mode) { case "json": return new JsonTypeSearchRenderer(); case "log": - return new InkTypeSearchRenderer(); + return new InkTypeSearchRenderer(fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderTypeResultLine(item: TypeSearchItem): React.ReactElement { + const rawLabel = item.raw !== item.normalized ? ` (${item.raw})` : undefined; + return ( + + {item.normalized} + {rawLabel !== undefined && {rawLabel}} + + ); +} + +/** Renders preview content for a type. */ +function renderTypePreview( + item: TypeSearchItem, + detail: TypeDescribeData | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + return ( + + {item.normalized} + {item.raw !== item.normalized && raw: {item.raw}} + + ); + } + + // Full detail from fetchPreview + return ( + + {detail.type.normalized} + {detail.type.raw !== detail.type.normalized && ( + raw: {detail.type.raw} + )} + version: {detail.version} + {detail.methods && detail.methods.length > 0 && ( + + Methods: + {detail.methods.map((method) => ( + + + {method.name} + - {method.description} + + {renderMethodArguments(method.arguments)} + + ))} + + )} + + ); +} + +/** Renders JSON Schema arguments as Ink elements for the preview pane. */ +function renderMethodArguments(schema: object): React.ReactElement | null { + const s = schema as { + properties?: Record< + string, + { type?: string; enum?: string[]; description?: string } + >; + required?: string[]; + }; + if (!s.properties) return null; + + const required = new Set(s.required ?? []); + const entries = Object.entries(s.properties); + if (entries.length === 0) return null; + + return ( + + Arguments: + {entries.map(([name, prop]) => ( + + {INDENT_4} + {name} + {prop.type ? ({prop.type}) : null} + {prop.enum ? [{prop.enum.join(", ")}] : null} + {required.has(name) ? *required : null} + + ))} + + ); +} + +/** Produces plain-text scrollback output for a selected type. */ +function renderTypeScrollback( + item: TypeSearchItem, + detail: TypeDescribeData | undefined, +): string { + if (!detail) { + if (item.raw !== item.normalized) { + return `${item.normalized} (${item.raw})`; + } + return item.normalized; + } + + const lines: string[] = [ + `${detail.type.normalized} v${detail.version}`, + ]; + + if (detail.type.raw !== detail.type.normalized) { + lines.push(`raw: ${detail.type.raw}`); + } + + if (detail.methods && detail.methods.length > 0) { + lines.push(""); + lines.push("Methods:"); + lines.push(...formatMethodLines(detail.methods)); + } + + return lines.join("\n"); +} diff --git a/src/presentation/renderers/vault_search.tsx b/src/presentation/renderers/vault_search.tsx index cf8297a2..6ec588a9 100644 --- a/src/presentation/renderers/vault_search.tsx +++ b/src/presentation/renderers/vault_search.tsx @@ -22,6 +22,7 @@ import React from "react"; import { Box, Text } from "ink"; import type { EventHandlers, + VaultDescribeData, VaultSearchData, VaultSearchEvent, VaultSearchItem, @@ -29,7 +30,7 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; /** * Filters vaults by a query string (case-insensitive match on name, type, or id). @@ -48,6 +49,13 @@ function filterVaults( ); } +/** + * Callback type for fetching vault detail data for the preview pane. + */ +export type VaultPreviewFetcher = ( + item: VaultSearchItem, +) => Promise; + export type VaultSearchRenderer = SearchRenderer< VaultSearchEvent, VaultSearchItem @@ -80,6 +88,11 @@ class JsonVaultSearchRenderer implements VaultSearchRenderer { class InkVaultSearchRenderer implements VaultSearchRenderer { private _selected: VaultSearchItem | undefined; + private readonly fetchPreview: VaultPreviewFetcher | undefined; + + constructor(fetchPreview?: VaultPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): VaultSearchItem | undefined { return this._selected; @@ -89,31 +102,25 @@ class InkVaultSearchRenderer implements VaultSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + VaultSearchItem, + VaultDescribeData + >( e.data.results, e.data.query, (item) => `${item.name} ${item.type} ${item.id}`, - (item, isSelected) => ( - - - - {isSelected ? "> " : " "} - {item.name} - - ({item.type}) - - {isSelected && ( - - ID: {item.id} - - )} - - ), + renderVaultResultLine, + renderVaultPreview, + renderVaultScrollback, "vaults", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.id, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -124,11 +131,80 @@ class InkVaultSearchRenderer implements VaultSearchRenderer { export function createVaultSearchRenderer( mode: OutputMode, + fetchPreview?: VaultPreviewFetcher, ): VaultSearchRenderer { switch (mode) { case "json": return new JsonVaultSearchRenderer(); case "log": - return new InkVaultSearchRenderer(); + return new InkVaultSearchRenderer(fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderVaultResultLine(item: VaultSearchItem): React.ReactElement { + return ( + + {item.name} ({item.type}) + + ); +} + +/** Renders preview content for a vault. */ +function renderVaultPreview( + item: VaultSearchItem, + detail: VaultDescribeData | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + return ( + + {item.name} + type: {item.type} + id: {item.id} + + ); + } + + // Full detail from fetchPreview + const configJson = JSON.stringify(detail.config, null, 2); + return ( + + {detail.name} + type: {detail.type} + id: {detail.id} + created: {detail.createdAt} + + Config: + {configJson} + + + ); +} + +/** Produces plain-text scrollback output for a selected vault. */ +function renderVaultScrollback( + item: VaultSearchItem, + detail: VaultDescribeData | undefined, +): string { + if (!detail) { + return `${item.name} (${item.type})\nID: ${item.id}`; + } + + const lines: string[] = [ + `${detail.name} (${detail.type})`, + `ID: ${detail.id}`, + `created: ${detail.createdAt}`, + "", + "Config:", + JSON.stringify(detail.config, null, 2), + ]; + + return lines.join("\n"); +} diff --git a/src/presentation/renderers/vault_type_search.tsx b/src/presentation/renderers/vault_type_search.tsx index 0f16a5a0..7f06d333 100644 --- a/src/presentation/renderers/vault_type_search.tsx +++ b/src/presentation/renderers/vault_type_search.tsx @@ -29,7 +29,7 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; /** * Filters vault types by a query string (case-insensitive match on type, name, @@ -90,31 +90,21 @@ class InkVaultTypeSearchRenderer implements VaultTypeSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker( e.data.results, e.data.query, (item) => `${item.type} ${item.name} ${item.description}`, - (item, isSelected) => ( - - - - {isSelected ? "> " : " "} - {item.type} - - - {item.name} - - {isSelected && ( - - {item.description} - - )} - - ), + renderVaultTypeResultLine, + renderVaultTypePreview, + renderVaultTypeScrollback, "vault types", + { + previewKeyFn: (item) => item.type, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -133,3 +123,42 @@ export function createVaultTypeSearchRenderer( return new InkVaultTypeSearchRenderer(); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderVaultTypeResultLine( + item: VaultTypeSearchItem, +): React.ReactElement { + return ( + + {item.type} - {item.name} + + ); +} + +/** Renders preview content for a vault type. */ +function renderVaultTypePreview( + item: VaultTypeSearchItem, + _detail: VaultTypeSearchItem | undefined, + _width: number, + _height: number, +): React.ReactElement { + return ( + + {item.type} + name: {item.name} + {item.description} + + ); +} + +/** Produces plain-text scrollback output for a selected vault type. */ +function renderVaultTypeScrollback( + item: VaultTypeSearchItem, + _detail: VaultTypeSearchItem | undefined, +): string { + return `${item.type} - ${item.name}\n${item.description}`; +} diff --git a/src/presentation/renderers/workflow_history_search.tsx b/src/presentation/renderers/workflow_history_search.tsx index 79511ee2..c154c030 100644 --- a/src/presentation/renderers/workflow_history_search.tsx +++ b/src/presentation/renderers/workflow_history_search.tsx @@ -25,11 +25,38 @@ import type { WorkflowHistorySearchData, WorkflowHistorySearchEvent, WorkflowHistorySearchItem, + WorkflowRunView, } from "../../libswamp/mod.ts"; import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; + +const STATUS_COLORS: Record = { + pending: "gray", + running: "yellow", + succeeded: "green", + failed: "red", +}; + +const STATUS_ICONS: Record = { + succeeded: "\u2713", + failed: "\u2717", + running: "\u25CB", + pending: "\u25CB", + skipped: "\u2014", +}; + +function formatDurationSec(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Callback type for fetching history run detail data for the preview pane. + */ +export type HistoryPreviewFetcher = ( + item: WorkflowHistorySearchItem, +) => Promise; /** * Filters runs by a query string (case-insensitive match on workflowName, runId, or status). @@ -82,6 +109,11 @@ class JsonWorkflowHistorySearchRenderer class InkWorkflowHistorySearchRenderer implements WorkflowHistorySearchRenderer { private _selected: WorkflowHistorySearchItem | undefined; + private readonly fetchPreview: HistoryPreviewFetcher | undefined; + + constructor(fetchPreview?: HistoryPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): WorkflowHistorySearchItem | undefined { return this._selected; @@ -91,8 +123,9 @@ class InkWorkflowHistorySearchRenderer return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch< - WorkflowHistorySearchItem + const result = await renderInteractivePicker< + WorkflowHistorySearchItem, + WorkflowRunView >( e.data.results, e.data.query, @@ -105,56 +138,18 @@ class InkWorkflowHistorySearchRenderer return `${item.workflowName} ${item.runId} ${item.status} ${tagStr}` .trim(); }, - (item, isSelected) => { - const statusColors: Record = { - pending: "gray", - running: "yellow", - succeeded: "green", - failed: "red", - }; - const statusColor = statusColors[item.status] ?? "white"; - const dateStr = item.startedAt - ? new Date(item.startedAt).toLocaleString() - : "not started"; - const durationStr = item.duration !== undefined - ? `${(item.duration / 1000).toFixed(1)}s` - : ""; - const tagStr = item.tags && Object.keys(item.tags).length > 0 - ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join( - ", ", - ) - : ""; - - return ( - - - {isSelected ? "\u25B6 " : " "} - {item.workflowName} - - - [{item.status}] - - {dateStr} - {durationStr && ( - <> - - {durationStr} - - )} - {tagStr && ( - <> - - [{tagStr}] - - )} - - ); - }, + renderHistoryResultLine, + renderHistoryPreview, + renderHistoryScrollback, "runs", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.runId, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -165,11 +160,207 @@ class InkWorkflowHistorySearchRenderer export function createWorkflowHistorySearchRenderer( mode: OutputMode, + fetchPreview?: HistoryPreviewFetcher, ): WorkflowHistorySearchRenderer { switch (mode) { case "json": return new JsonWorkflowHistorySearchRenderer(); case "log": - return new InkWorkflowHistorySearchRenderer(); + return new InkWorkflowHistorySearchRenderer(fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderHistoryResultLine( + item: WorkflowHistorySearchItem, +): React.ReactElement { + const statusColor = STATUS_COLORS[item.status] ?? "white"; + const dateStr = item.startedAt + ? new Date(item.startedAt).toLocaleString() + : "not started"; + const durationStr = item.duration !== undefined + ? `${(item.duration / 1000).toFixed(1)}s` + : ""; + const tagStr = item.tags && Object.keys(item.tags).length > 0 + ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : ""; + + return ( + + {`${item.workflowName} `} + {`[${item.status}]`} + {` ${dateStr}`} + {durationStr ? ` ${durationStr}` : ""} + {tagStr ? {` [${tagStr}]`} : null} + + ); +} + +/** Preview content for a workflow history run. */ +function renderHistoryPreview( + item: WorkflowHistorySearchItem, + detail: WorkflowRunView | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + const statusColor = STATUS_COLORS[item.status] ?? "white"; + const dateStr = item.startedAt + ? new Date(item.startedAt).toLocaleString() + : "not started"; + const completedStr = item.completedAt + ? new Date(item.completedAt).toLocaleString() + : ""; + const durationStr = item.duration !== undefined + ? `${(item.duration / 1000).toFixed(1)}s` + : ""; + const tagStr = item.tags && Object.keys(item.tags).length > 0 + ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : ""; + + return ( + + {item.workflowName} + run: {item.runId} + + status: {item.status} + + started: {dateStr} + {completedStr && completed: {completedStr}} + {durationStr && duration: {durationStr}} + {tagStr && tags: {tagStr}} + + ); + } + + // Full detail from fetchPreview + const statusColor = STATUS_COLORS[detail.status] ?? "white"; + const durationStr = detail.duration !== undefined + ? formatDurationSec(detail.duration) + : ""; + + return ( + + {detail.workflowName} + run: {detail.id} + + status: {detail.status} + + {durationStr && duration: {durationStr}} + + {detail.jobs.length > 0 && ( + + Jobs: + {detail.jobs.map((job) => { + const jobIcon = STATUS_ICONS[job.status] ?? " "; + const jobColor = STATUS_COLORS[job.status] ?? "white"; + const jobDur = job.duration !== undefined + ? ` (${formatDurationSec(job.duration)})` + : ""; + return ( + + + {jobIcon}{" "} + {job.name} + {jobDur} + + {job.steps.map((step) => { + const stepIcon = STATUS_ICONS[step.status] ?? " "; + const stepColor = STATUS_COLORS[step.status] ?? "white"; + const stepDur = step.duration !== undefined + ? ` (${formatDurationSec(step.duration)})` + : ""; + return ( + + + {stepIcon} {step.name} + {stepDur} + {step.error && - {step.error}} + + + ); + })} + + ); + })} + + )} + + ); +} + +/** Plain-text scrollback output for a selected workflow history run. */ +function renderHistoryScrollback( + item: WorkflowHistorySearchItem, + detail: WorkflowRunView | undefined, +): string { + if (!detail) { + const dateStr = item.startedAt + ? new Date(item.startedAt).toLocaleString() + : "not started"; + const durationStr = item.duration !== undefined + ? `${(item.duration / 1000).toFixed(1)}s` + : ""; + const tagStr = item.tags && Object.keys(item.tags).length > 0 + ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : ""; + + const lines: string[] = [ + `${item.workflowName} [${item.status}]`, + `run: ${item.runId}`, + `started: ${dateStr}`, + ]; + + if (item.completedAt) { + lines.push(`completed: ${new Date(item.completedAt).toLocaleString()}`); + } + if (durationStr) { + lines.push(`duration: ${durationStr}`); + } + if (tagStr) { + lines.push(`tags: ${tagStr}`); + } + + return lines.join("\n"); + } + + const durationStr = detail.duration !== undefined + ? formatDurationSec(detail.duration) + : ""; + + const lines: string[] = [ + `${detail.workflowName} [${detail.status}]`, + `run: ${detail.id}`, + ]; + + if (durationStr) { + lines.push(`duration: ${durationStr}`); + } + + if (detail.jobs.length > 0) { + lines.push(""); + lines.push("Jobs:"); + for (const job of detail.jobs) { + const jobIcon = STATUS_ICONS[job.status] ?? " "; + const jobDur = job.duration !== undefined + ? ` (${formatDurationSec(job.duration)})` + : ""; + lines.push(` ${jobIcon} ${job.name}${jobDur}`); + for (const step of job.steps) { + const stepIcon = STATUS_ICONS[step.status] ?? " "; + const stepDur = step.duration !== undefined + ? ` (${formatDurationSec(step.duration)})` + : ""; + const stepErr = step.error ? ` - ${step.error}` : ""; + lines.push(` ${stepIcon} ${step.name}${stepDur}${stepErr}`); + } + } + } + + return lines.join("\n"); +} diff --git a/src/presentation/renderers/workflow_run_search.tsx b/src/presentation/renderers/workflow_run_search.tsx index 82e01227..a0a833d6 100644 --- a/src/presentation/renderers/workflow_run_search.tsx +++ b/src/presentation/renderers/workflow_run_search.tsx @@ -25,11 +25,38 @@ import type { WorkflowRunSearchData, WorkflowRunSearchEvent, WorkflowRunSearchItem, + WorkflowRunView, } from "../../libswamp/mod.ts"; import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; + +const STATUS_COLORS: Record = { + pending: "gray", + running: "yellow", + succeeded: "green", + failed: "red", +}; + +const STATUS_ICONS: Record = { + succeeded: "\u2713", + failed: "\u2717", + running: "\u25CB", + pending: "\u25CB", + skipped: "\u2014", +}; + +function formatDurationSec(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Callback type for fetching run detail data for the preview pane. + */ +export type RunPreviewFetcher = ( + item: WorkflowRunSearchItem, +) => Promise; export type WorkflowRunSearchRenderer = SearchRenderer< WorkflowRunSearchEvent, @@ -62,6 +89,11 @@ class JsonWorkflowRunSearchRenderer implements WorkflowRunSearchRenderer { class InkWorkflowRunSearchRenderer implements WorkflowRunSearchRenderer { private _selected: WorkflowRunSearchItem | undefined; + private readonly fetchPreview: RunPreviewFetcher | undefined; + + constructor(fetchPreview?: RunPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): WorkflowRunSearchItem | undefined { return this._selected; @@ -71,7 +103,10 @@ class InkWorkflowRunSearchRenderer implements WorkflowRunSearchRenderer { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + WorkflowRunSearchItem, + WorkflowRunView + >( e.data.results, e.data.query, (item) => { @@ -83,56 +118,18 @@ class InkWorkflowRunSearchRenderer implements WorkflowRunSearchRenderer { return `${item.workflowName} ${item.runId} ${item.status} ${tagStr}` .trim(); }, - (item, isSelected) => { - const statusColors: Record = { - pending: "gray", - running: "yellow", - succeeded: "green", - failed: "red", - }; - const statusColor = statusColors[item.status] ?? "white"; - const dateStr = item.startedAt - ? new Date(item.startedAt).toLocaleString() - : "not started"; - const durationStr = item.duration !== undefined - ? `${(item.duration / 1000).toFixed(1)}s` - : ""; - const tagStr = item.tags && Object.keys(item.tags).length > 0 - ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join( - ", ", - ) - : ""; - - return ( - - - {isSelected ? "\u25B6 " : " "} - {item.workflowName} - - - [{item.status}] - - {dateStr} - {durationStr && ( - <> - - {durationStr} - - )} - {tagStr && ( - <> - - [{tagStr}] - - )} - - ); - }, + renderRunResultLine, + renderRunPreview, + renderRunScrollback, "runs", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.runId, + }, ); + if (result) { + this._selected = result.item; + } }, error: (e) => { throw new UserError(e.error.message); @@ -143,11 +140,207 @@ class InkWorkflowRunSearchRenderer implements WorkflowRunSearchRenderer { export function createWorkflowRunSearchRenderer( mode: OutputMode, + fetchPreview?: RunPreviewFetcher, ): WorkflowRunSearchRenderer { switch (mode) { case "json": return new JsonWorkflowRunSearchRenderer(); case "log": - return new InkWorkflowRunSearchRenderer(); + return new InkWorkflowRunSearchRenderer(fetchPreview); } } + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderRunResultLine( + item: WorkflowRunSearchItem, +): React.ReactElement { + const statusColor = STATUS_COLORS[item.status] ?? "white"; + const dateStr = item.startedAt + ? new Date(item.startedAt).toLocaleString() + : "not started"; + const durationStr = item.duration !== undefined + ? `${(item.duration / 1000).toFixed(1)}s` + : ""; + const tagStr = item.tags && Object.keys(item.tags).length > 0 + ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : ""; + + return ( + + {`${item.workflowName} `} + {`[${item.status}]`} + {` ${dateStr}`} + {durationStr ? ` ${durationStr}` : ""} + {tagStr ? {` [${tagStr}]`} : null} + + ); +} + +/** Preview content for a workflow run. */ +function renderRunPreview( + item: WorkflowRunSearchItem, + detail: WorkflowRunView | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + const statusColor = STATUS_COLORS[item.status] ?? "white"; + const dateStr = item.startedAt + ? new Date(item.startedAt).toLocaleString() + : "not started"; + const completedStr = item.completedAt + ? new Date(item.completedAt).toLocaleString() + : ""; + const durationStr = item.duration !== undefined + ? `${(item.duration / 1000).toFixed(1)}s` + : ""; + const tagStr = item.tags && Object.keys(item.tags).length > 0 + ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : ""; + + return ( + + {item.workflowName} + run: {item.runId} + + status: {item.status} + + started: {dateStr} + {completedStr && completed: {completedStr}} + {durationStr && duration: {durationStr}} + {tagStr && tags: {tagStr}} + + ); + } + + // Full detail from fetchPreview + const statusColor = STATUS_COLORS[detail.status] ?? "white"; + const durationStr = detail.duration !== undefined + ? formatDurationSec(detail.duration) + : ""; + + return ( + + {detail.workflowName} + run: {detail.id} + + status: {detail.status} + + {durationStr && duration: {durationStr}} + + {detail.jobs.length > 0 && ( + + Jobs: + {detail.jobs.map((job) => { + const jobIcon = STATUS_ICONS[job.status] ?? " "; + const jobColor = STATUS_COLORS[job.status] ?? "white"; + const jobDur = job.duration !== undefined + ? ` (${formatDurationSec(job.duration)})` + : ""; + return ( + + + {jobIcon}{" "} + {job.name} + {jobDur} + + {job.steps.map((step) => { + const stepIcon = STATUS_ICONS[step.status] ?? " "; + const stepColor = STATUS_COLORS[step.status] ?? "white"; + const stepDur = step.duration !== undefined + ? ` (${formatDurationSec(step.duration)})` + : ""; + return ( + + + {stepIcon} {step.name} + {stepDur} + {step.error && - {step.error}} + + + ); + })} + + ); + })} + + )} + + ); +} + +/** Plain-text scrollback output for a selected workflow run. */ +function renderRunScrollback( + item: WorkflowRunSearchItem, + detail: WorkflowRunView | undefined, +): string { + if (!detail) { + const dateStr = item.startedAt + ? new Date(item.startedAt).toLocaleString() + : "not started"; + const durationStr = item.duration !== undefined + ? `${(item.duration / 1000).toFixed(1)}s` + : ""; + const tagStr = item.tags && Object.keys(item.tags).length > 0 + ? Object.entries(item.tags).map(([k, v]) => `${k}=${v}`).join(", ") + : ""; + + const lines: string[] = [ + `${item.workflowName} [${item.status}]`, + `run: ${item.runId}`, + `started: ${dateStr}`, + ]; + + if (item.completedAt) { + lines.push(`completed: ${new Date(item.completedAt).toLocaleString()}`); + } + if (durationStr) { + lines.push(`duration: ${durationStr}`); + } + if (tagStr) { + lines.push(`tags: ${tagStr}`); + } + + return lines.join("\n"); + } + + const durationStr = detail.duration !== undefined + ? formatDurationSec(detail.duration) + : ""; + + const lines: string[] = [ + `${detail.workflowName} [${detail.status}]`, + `run: ${detail.id}`, + ]; + + if (durationStr) { + lines.push(`duration: ${durationStr}`); + } + + if (detail.jobs.length > 0) { + lines.push(""); + lines.push("Jobs:"); + for (const job of detail.jobs) { + const jobIcon = STATUS_ICONS[job.status] ?? " "; + const jobDur = job.duration !== undefined + ? ` (${formatDurationSec(job.duration)})` + : ""; + lines.push(` ${jobIcon} ${job.name}${jobDur}`); + for (const step of job.steps) { + const stepIcon = STATUS_ICONS[step.status] ?? " "; + const stepDur = step.duration !== undefined + ? ` (${formatDurationSec(step.duration)})` + : ""; + const stepErr = step.error ? ` - ${step.error}` : ""; + lines.push(` ${stepIcon} ${step.name}${stepDur}${stepErr}`); + } + } + } + + return lines.join("\n"); +} diff --git a/src/presentation/renderers/workflow_search.tsx b/src/presentation/renderers/workflow_search.tsx index 7f4884ae..fa3745d4 100644 --- a/src/presentation/renderers/workflow_search.tsx +++ b/src/presentation/renderers/workflow_search.tsx @@ -29,7 +29,8 @@ import type { import type { SearchRenderer } from "./search_renderer.ts"; import type { OutputMode } from "../output/output.ts"; import { UserError } from "../../domain/errors.ts"; -import { renderInteractiveSearch } from "./components/search_tui.tsx"; +import { renderInteractivePicker } from "./components/search_picker.tsx"; +import { renderMarkdownToTerminal } from "../markdown_renderer.ts"; /** * Filters workflows by a query string (case-insensitive match on name, id, or description). @@ -48,10 +49,30 @@ function filterWorkflows( ); } -export type WorkflowSearchRenderer = SearchRenderer< - WorkflowSearchEvent, - WorkflowSearchItem ->; +/** + * Detail data for the preview pane — the raw YAML file content. + */ +export interface WorkflowPreviewDetail { + yaml: string; + name: string; +} + +/** + * Callback type for fetching workflow YAML for the preview pane. + */ +export type WorkflowPreviewFetcher = ( + item: WorkflowSearchItem, +) => Promise; + +export interface WorkflowSearchRendererResult { + item: WorkflowSearchItem; + action?: string; +} + +export interface WorkflowSearchRenderer + extends SearchRenderer { + selectedAction(): string | undefined; +} class JsonWorkflowSearchRenderer implements WorkflowSearchRenderer { private _selected: WorkflowSearchItem | undefined; @@ -60,6 +81,10 @@ class JsonWorkflowSearchRenderer implements WorkflowSearchRenderer { return this._selected; } + selectedAction(): string | undefined { + return undefined; + } + handlers(): EventHandlers { return { resolving: () => {}, @@ -85,39 +110,48 @@ class JsonWorkflowSearchRenderer implements WorkflowSearchRenderer { class InkWorkflowSearchRenderer implements WorkflowSearchRenderer { private _selected: WorkflowSearchItem | undefined; + private _action: string | undefined; + private readonly fetchPreview: WorkflowPreviewFetcher | undefined; + + constructor(fetchPreview?: WorkflowPreviewFetcher) { + this.fetchPreview = fetchPreview; + } selectedItem(): WorkflowSearchItem | undefined { return this._selected; } + selectedAction(): string | undefined { + return this._action; + } + handlers(): EventHandlers { return { resolving: () => {}, completed: async (e) => { - this._selected = await renderInteractiveSearch( + const result = await renderInteractivePicker< + WorkflowSearchItem, + WorkflowPreviewDetail + >( e.data.results, e.data.query, (item) => `${item.name} ${item.id} ${item.description ?? ""}`, - (item, isSelected) => ( - - - {isSelected ? "\u25B6 " : " "} - {item.name} - - ({item.jobCount} jobs) - {item.description && ( - <> - - {item.description} - - )} - - ), + renderWorkflowResultLine, + renderWorkflowPreview, + renderWorkflowScrollback, "workflows", + { + fetchPreview: this.fetchPreview, + previewKeyFn: (item) => item.id, + actions: [ + { key: "r", label: "Run", action: "run" }, + ], + }, ); + if (result) { + this._selected = result.item; + this._action = result.action; + } }, error: (e) => { throw new UserError(e.error.message); @@ -128,11 +162,72 @@ class InkWorkflowSearchRenderer implements WorkflowSearchRenderer { export function createWorkflowSearchRenderer( mode: OutputMode, + fetchPreview?: WorkflowPreviewFetcher, ): WorkflowSearchRenderer { switch (mode) { case "json": return new JsonWorkflowSearchRenderer(); case "log": - return new InkWorkflowSearchRenderer(); + return new InkWorkflowSearchRenderer(fetchPreview); + } +} + +// --------------------------------------------------------------------------- +// Rendering callbacks for the SearchPicker +// --------------------------------------------------------------------------- + +/** Single-line result for the results list. */ +function renderWorkflowResultLine( + item: WorkflowSearchItem, +): React.ReactElement { + const desc = item.description ? ` ${item.description}` : ""; + return ( + + {`${item.name} `} + {`(${item.jobCount} jobs)${desc}`} + + ); +} + +/** Preview content for a workflow — shows YAML when available. */ +function renderWorkflowPreview( + item: WorkflowSearchItem, + detail: WorkflowPreviewDetail | undefined, + _width: number, + _height: number, +): React.ReactElement { + if (!detail) { + // Immediate content from the search item + return ( + + {item.name} + {item.description && {item.description}} + {`${item.jobCount} jobs`} + + ); } + + // Show YAML with syntax highlighting + const rendered = renderMarkdownToTerminal( + `**${detail.name}**\n\n\`\`\`yaml\n${detail.yaml}\n\`\`\``, + ); + return ( + + {rendered} + + ); +} + +/** Plain-text scrollback output — rendered YAML. */ +function renderWorkflowScrollback( + item: WorkflowSearchItem, + detail: WorkflowPreviewDetail | undefined, +): string { + if (!detail) { + return `${item.name} (${item.jobCount} jobs)`; + } + + return renderMarkdownToTerminal( + `**${detail.name}**\n\n\`\`\`yaml\n${detail.yaml}\n\`\`\``, + ); }