Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@
"hyparquet": "1.26.0",
"hyparquet-compressors": "1.1.1",
"icebird": "0.8.5",
"squirreling": "0.12.22"
"squirreling": "0.12.23"
},
"optionalDependencies": {
"hyparquet-writer": "0.15.4"
"hyparquet-writer": "0.15.6"
},
"overrides": {
"hyparquet-writer": "0.15.4",
"squirreling": "0.12.22"
"hyparquet-writer": "0.15.6",
"squirreling": "0.12.23"
},
"devDependencies": {
"@types/node": "25.9.1",
Expand Down
5 changes: 5 additions & 0 deletions src/core/cli/core_commands.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { createCommandRegistry } from '../registry/commands.js'
import type { InitFlags, PickerExport, PickerExportOrigin } from './types.d.ts'

export declare function registerCoreCommands(
registry: ReturnType<typeof createCommandRegistry>
): void

export declare function resolveInitExportChoice(
flags: InitFlags
): { exportChoice: PickerExport; origin: PickerExportOrigin }
32 changes: 27 additions & 5 deletions src/core/cli/core_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
* @import { ConfirmInstall } from '../plugin_install/types.d.ts'
* @import { QueryFormat, RefreshMode } from '../query/types.d.ts'
* @import { ExtendedSinkRegistry, ExtendedSourceRegistry } from '../registry/types.d.ts'
* @import { CommandRegistryExtended, InitFlags, PickerBackfillRunner } from './types.d.ts'
* @import { CommandRegistryExtended, InitFlags, PickerBackfillRunner, PickerExport, PickerExportOrigin } from './types.d.ts'
*/

/**
Expand Down Expand Up @@ -2294,6 +2294,24 @@ function parseInitFlags(argv) {
return { flags }
}

/**
* Resolve the export choice for non-interactive `hyp init`. When
* `--export` is omitted the default is `local-parquet`, matching the
* interactive wizard so equivalent source selections produce the same
* durable-files-out-of-the-box config whether the operator used flags or
* the TUI. `origin` lets telemetry tell an explicit `--export` pick from a
* defaulted one. Pass `--export keep-local` for cache-only.
*
* @param {InitFlags} flags
* @returns {{ exportChoice: PickerExport, origin: PickerExportOrigin }}
*/
export function resolveInitExportChoice(flags) {
if (flags.exportChoice) {
return { exportChoice: flags.exportChoice, origin: 'user' }
}
return { exportChoice: 'local-parquet', origin: 'default' }
}

/**
* Non-interactive Phase 5 init. Composes picks from CLI flags,
* optionally seeds the config from a file (`--from-file`), and
Expand All @@ -2312,9 +2330,9 @@ async function runPickerInit(flags, ctx) {
return runInitFromFile(flags, ctx)
}

// Default picks when `--yes` is the only signal: capture Claude +
// OTEL, export to local Parquet. Matches the default V1 install
// documented in finish-v1.md §V1 Acceptance Criteria.
// Default sources when `--yes` is the only signal: capture Claude +
// OTEL. Matches the default V1 install documented in finish-v1.md
// §V1 Acceptance Criteria. (Export defaults separately, below.)
const sources = flags.sources.slice()
if (sources.length === 0) {
if (flags.yes) {
Expand All @@ -2330,7 +2348,10 @@ async function runPickerInit(flags, ctx) {
if (!sources.includes(c)) sources.push(c)
}

const exportChoice = flags.exportChoice ?? (flags.yes ? 'local-parquet' : 'keep-local')
// Export defaults to local-parquet whenever `--export` is omitted, so
// flag-driven init matches the interactive wizard rather than diverging
// to a conservative keep-local default for the same source selection.
const { exportChoice, origin: exportOrigin } = resolveInitExportChoice(flags)

const result = await runPickerWalkthrough({
capabilities: ctx.capabilities,
Expand All @@ -2344,6 +2365,7 @@ async function runPickerInit(flags, ctx) {
exportChoice,
retentionDays: flags.retentionDays,
},
exportOrigin,
backfill: buildPickerBackfillRunner(ctx),
finale: {
skipDaemon: flags.noDaemon,
Expand Down
9 changes: 7 additions & 2 deletions src/core/cli/tui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { PromptCancelledError }
* @property {NodeJS.ReadableStream} [stdin]
* @property {NodeJS.WritableStream} [stdout]
* @property {NodeJS.ProcessEnv} [env]
* @property {boolean} [clearOnResolve]
*/

/**
Expand Down Expand Up @@ -72,6 +73,7 @@ export async function multiselect(spec) {
* @property {NodeJS.ReadableStream} [stdin]
* @property {NodeJS.WritableStream} [stdout]
* @property {NodeJS.ProcessEnv} [env]
* @property {boolean} [clearOnResolve]
*/

/**
Expand Down Expand Up @@ -115,6 +117,7 @@ export async function select(spec) {
* @property {NodeJS.ReadableStream} [stdin]
* @property {NodeJS.WritableStream} [stdout]
* @property {NodeJS.ProcessEnv} [env]
* @property {boolean} [clearOnResolve]
*/

/**
Expand Down Expand Up @@ -150,6 +153,7 @@ export async function text(spec) {
* @property {NodeJS.ReadableStream} [stdin]
* @property {NodeJS.WritableStream} [stdout]
* @property {NodeJS.ProcessEnv} [env]
* @property {boolean} [clearOnResolve]
*/

/**
Expand All @@ -173,13 +177,14 @@ export async function confirm(spec) {
}

/**
* @param {{ stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, env?: NodeJS.ProcessEnv }} spec
* @returns {{ stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, env?: NodeJS.ProcessEnv }}
* @param {{ stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, env?: NodeJS.ProcessEnv, clearOnResolve?: boolean }} spec
* @returns {{ stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, env?: NodeJS.ProcessEnv, clearOnResolve?: boolean }}
*/
function resolveIo(spec) {
return {
stdin: spec.stdin ?? process.stdin,
stdout: spec.stdout ?? process.stdout,
...(spec.env !== undefined ? { env: spec.env } : {}),
...(spec.clearOnResolve ? { clearOnResolve: true } : {}),
}
}
74 changes: 64 additions & 10 deletions src/core/cli/tui/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ let activeRun = false
* @property {NodeJS.ReadableStream} stdin
* @property {NodeJS.WritableStream} stdout
* @property {NodeJS.ProcessEnv} [env]
* @property {boolean} [clearOnResolve] Erase the prompt's frame from the
* terminal when it settles (resolve or cancel) so the next prompt
* redraws in its place instead of stacking below it.
*/

/**
Expand All @@ -40,6 +43,7 @@ export async function run(initialState, io) {
activeRun = true

const color = env.NO_COLOR ? false : true
const clearOnResolve = io.clearOnResolve === true
/** @type {NodeJS.ReadStream} */
const stdin = /** @type {any} */ (io.stdin)
const stdout = io.stdout
Expand Down Expand Up @@ -75,6 +79,13 @@ export async function run(initialState, io) {
stdin.pause()
}
} catch {}
if (clearOnResolve && previousLineCount > 0) {
// Move the cursor back to the top of the rendered frame and clear
// everything below it, leaving the screen as it was before the
// prompt drew. The next prompt then redraws in the same position.
try { stdout.write(`\x1b[${previousLineCount}A\r${CLEAR_TO_END}`) } catch {}
previousLineCount = 0
}
try { stdout.write(CURSOR_SHOW) } catch {}
}

Expand All @@ -85,7 +96,7 @@ export async function run(initialState, io) {
}
const frame = render(state, { color })
buf += frame
previousLineCount = countTrailingLines(frame)
previousLineCount = countPhysicalRows(frame, terminalColumns(stdout))
stdout.write(buf)
}

Expand Down Expand Up @@ -160,17 +171,60 @@ function normalizeKey(str, key) {
}

/**
* Count the number of newline characters in `s`. The runtime uses this
* to know how far to move the cursor up before clearing the previous
* frame. Frames always end with `\n`, so the value equals the number of
* rows the frame occupied below the start point.
* Resolve the terminal width in columns, defaulting to 80 when the
* stream does not expose a usable `.columns` (non-TTY mocks, pipes).
*
* @param {NodeJS.WritableStream} stdout
* @returns {number}
*/
function terminalColumns(stdout) {
const cols = /** @type {any} */ (stdout).columns
return typeof cols === 'number' && cols > 0 ? cols : 80
}

// Match ANSI SGR (color/style) sequences so they are excluded from the
// visible-width measurement. The renderer only emits `\x1b[...m` codes.
const ANSI_SGR = /\x1b\[[0-9;]*m/g

/**
* Visible (printable) width of a single logical line, ignoring ANSI
* style codes. Measured in code units, which matches column count for
* the Latin/punctuation text the prompts render.
*
* @param {string} line
* @returns {number}
*/
function visibleWidth(line) {
return line.replace(ANSI_SGR, '').length
}

/**
* Count the number of *physical* terminal rows a frame occupies. The
* runtime uses this to know how far to move the cursor up before
* clearing the previous frame. A naive newline count is wrong whenever
* a logical line is wider than the terminal: the terminal soft-wraps it
* onto multiple rows, so the cursor descended further than the number of
* `\n` written. Undercounting here leaves stale rows on screen on every
* redraw — the classic "the question keeps duplicating when I move the
* cursor" symptom.
*
* Frames always end with a trailing `\n`; the empty segment after it
* contributes no row.
*
* @param {string} s
* @param {string} frame
* @param {number} columns
* @returns {number}
*/
function countTrailingLines(s) {
let n = 0
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++
return n
export function countPhysicalRows(frame, columns) {
const width = columns > 0 ? columns : 80
const lines = frame.split('\n')
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
let rows = 0
for (const line of lines) {
const len = visibleWidth(line)
rows += len === 0 ? 1 : Math.ceil(len / width)
}
return rows
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/core/cli/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export type AsyncBackfillConsentPrompt = (args: {
export type PickerSource = 'claude' | 'codex' | 'raw-anthropic' | 'raw-openai' | 'otel'
export type PickerExport = 'keep-local' | 'local-parquet' | 'configure-later'

/**
* Provenance of a resolved export choice for telemetry. `user` means the
* operator picked it explicitly (an `--export` flag); `default` means the
* system supplied it (the interactive wizard or an omitted `--export`).
*/
export type PickerExportOrigin = 'default' | 'user'

export interface WalkthroughOption {
/** Stable identifier (source name, sink contribution key, client name). */
value: string
Expand Down Expand Up @@ -94,6 +101,12 @@ export interface RunPickerWalkthroughOptions {
env: NodeJS.ProcessEnv
/** Pre-baked picks; bypass prompts when set. */
picks?: PickerPicks
/**
* Provenance of `picks.exportChoice`, for telemetry only. Consulted
* solely on the pre-baked path (with `picks`); the interactive wizard
* always defaults export, so its origin is `default`. Omit to default.
*/
exportOrigin?: PickerExportOrigin
prompt?: AsyncPickPrompt
retentionPrompt?: AsyncRetentionPrompt
/**
Expand Down
Loading