Skip to content
Open
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
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
60 changes: 38 additions & 22 deletions src/core/cli/walkthrough.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { discoverBundledPlugins } from '../runtime/bundled.js'
import { buildPluginCatalog } from '../plugin_catalog.js'
import { ensureDurableBinForNpx } from './global_install.js'
import { detectClientSources } from './detect.js'
import { multiselect, text, confirm } from './tui/index.js'
import { multiselect, select, text } from './tui/index.js'
import { isPromptCancelledError } from './tui/runtime.js'
import { shouldUseTui } from './tui-router.js'

Expand Down Expand Up @@ -426,6 +426,7 @@ function tuiPromptFactory(opts) {
...(o.checked ? { checked: true } : {}),
})),
...(question.bounds ? { bounds: question.bounds } : {}),
clearOnResolve: true,
stdin: opts.stdin ?? process.stdin,
stdout: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (opts.stdout)),
env: opts.env,
Expand All @@ -452,6 +453,7 @@ function tuiRetentionPromptFactory(opts) {
const n = Number.parseInt(s.trim(), 10)
return Number.isInteger(n) && n >= 0 ? null : 'enter a non-negative integer'
},
clearOnResolve: true,
stdin: opts.stdin ?? process.stdin,
stdout: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (opts.stdout)),
env: opts.env,
Expand Down Expand Up @@ -488,9 +490,9 @@ function defaultRetentionPromptFactory(opts) {

/**
* Build the interactive backfill-consent prompt. Routes to the TUI
* yes/no confirm on a real TTY, else a legacy readline yes/no. Both
* default to yes so a bare enter opts in — the bead's "default backfill
* to enabled, but let the user choose no".
* arrow-navigable yes/no select on a real TTY, else a legacy readline
* yes/no. Both default to yes so a bare enter opts in — the bead's
* "default backfill to enabled, but let the user choose no".
*
* @param {Pick<WalkthroughOptions, 'stdin' | 'stdout' | 'env'>} opts
* @returns {AsyncBackfillConsentPrompt}
Expand All @@ -501,18 +503,28 @@ function defaultBackfillConsentPromptFactory(opts) {
}

/**
* Render the backfill consent as a `select` so it matches the look and
* feel of the source picker (arrow keys + pointer) rather than a plain
* y/n confirm. Cursor defaults to "Yes" so a bare enter opts in.
*
* @param {Pick<WalkthroughOptions, 'stdin' | 'stdout' | 'env'>} opts
* @returns {AsyncBackfillConsentPrompt}
*/
function tuiBackfillConsentPromptFactory(opts) {
return async function ({ providers, retentionDays }) {
return confirm({
const choice = await select({
title: backfillConsentTitle(providers, retentionDays),
default: true,
options: [
{ value: 'yes', label: 'Yes — import it now', summary: 'Reads local transcripts into the query cache.' },
{ value: 'no', label: 'No — skip for now', summary: 'You can import later with hyp backfill.' },
],
default: 'yes',
clearOnResolve: true,
stdin: opts.stdin ?? process.stdin,
stdout: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (opts.stdout)),
env: opts.env,
})
return choice === 'yes'
}
}

Expand Down Expand Up @@ -690,22 +702,13 @@ export async function runPickerWalkthrough(opts) {
sourceRaw.filter((v) => PICKER_SOURCES.some((s) => s.value === v))
)

const exportRaw = await ask({
pickType: 'sinks',
title: 'Where should HypAware export captured data?',
options: PICKER_EXPORTS.map((e) => ({
value: e.value,
label: e.label,
summary: e.summary,
// Default export: pre-check local Parquet so the interactive
// picker matches the documented `--yes` default (which also
// defaults to local-parquet). A plain default, not autodetect.
...(e.value === 'local-parquet' ? { checked: true } : {}),
})),
})
const exportChoice = /** @type {PickerExport} */ (
PICKER_EXPORTS.find((e) => exportRaw.includes(e.value))?.value ?? 'keep-local'
)
// Export destination is not asked interactively. A local query
// cache is always kept; on top of it we default to scheduled local
// Parquet exports so `npx hypaware` produces durable files out of
// the box. Other destinations (keep-local only, configure-later,
// S3, …) remain available via `hyp init --export <choice>` and by
// editing the written config later.
const exportChoice = /** @type {PickerExport} */ ('local-parquet')

const retentionDays = await retentionAsk('Cache retention (days)', DEFAULT_RETENTION_DAYS)
picks = { sources, exportChoice, retentionDays }
Expand Down Expand Up @@ -1100,12 +1103,16 @@ async function runPickerFinale(args) {
status: 'ok',
},
async (span) => {
let printedAny = false
for (const skill of skills.list()) {
for (const targetClient of skill.clients) {
if (!clientsPicked.includes(targetClient)) continue
const skillDir = skillDirMap.get(targetClient)
if (!skillDir) continue
const dest = path.join(homeDir, skillDir, skill.name)
// Separate the skills block from the preceding attach output.
if (!printedAny) stdout.write('\n')
printedAny = true
if (dryRun) {
stdout.write(`(dry-run) Would install skill '${skill.name}' → ${dest}\n`)
} else {
Expand All @@ -1116,6 +1123,8 @@ async function runPickerFinale(args) {
summary.skillsInstalled.push({ name: skill.name, client: targetClient, dest, dryRun })
}
}
// Trailing blank line so the next step (backfill prompt) stands apart.
if (printedAny) stdout.write('\n')
if (span && typeof span.setAttribute === 'function') {
span.setAttribute('installed_count', summary.skillsInstalled.length)
}
Expand Down Expand Up @@ -1244,6 +1253,13 @@ async function runFinaleBackfill(args) {
// matching the attach/restart resilience above.
for (const provider of providers) {
try {
// Importing local history reads and writes potentially
// thousands of rows with no other output. Without this line
// the resolved consent frame is the last thing on screen, so a
// multi-second import looks like the prompt is stuck. Announce
// the work before it starts so the wizard visibly moves on.
const startTag = dryRun ? '(dry-run) ' : ''
stdout.write(`${startTag}backfill ${provider}: importing local history…\n`)
const entry = await backfill.run({ provider, dryRun, retentionDays, until })
summary.backfill.push(entry)
const tag = entry.dryRun ? '(dry-run) ' : ''
Expand Down
Loading