feat(cli): print extension results as key/value rows and sub-tables

The generated QBit commands (alerts get-inbox, sql-workbench
get-capabilities, collab get-..., api) printed lists as one line of JSON
in a key/value table (`items [{"id":5,...`). The kernel's render model
gains a document layout (formatDocument): scalars as aligned key/value
rows with nested objects as dotted keys, each list of objects as a
sub-table under its key with columns from the union of the row keys in
first-seen order, nested objects flattened one level, lists in a cell
joined, fitted to the terminal width (cells cut with an ellipsis, columns
that do not fit named under the table). render() uses it for nested
results and for any result given a layout; json, yaml, csv and text do not
change, and a --query reshape drops the column choice.

The inbox, notebooks, SQL history, comments, tasks, favorites and recents
get default columns (DEFAULT_LIST_COLUMNS); --columns (or the global
--fields) chooses, on the generated commands and on the first-class collab
comments list, favorites list, recents, tags list and people.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-01 05:27:39 -06:00
1 parent 0687083c56
commit 5e34433a86
16 files changed
+396 -30

No files matched your search

+10 -2
View File
@@ -5,7 +5,7 @@
import { basename } from 'node:path'
import type { Command } from 'commander'
import { OPERATIONS, operation, type OperationInfo } from '@qrun-io/qqq-core'
import { OPERATIONS, defaultListColumns, operation, parseColumnList, type OperationInfo } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { parsePairs, readFileArg, readJson, writeDownload } from './shared.js'
@@ -44,6 +44,8 @@ export interface OperationOptions {
form?: string[]
bytes?: string
out?: string
/** Table output: the columns of the result's lists (comma-separated). */
columns?: string
}
/**
@@ -77,5 +79,11 @@ export async function runOperation(c: Context, op: OperationInfo, options: Opera
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data })
c.print({ data: result.data, layout: { columns: listColumns(c, op, options) } })
}
/** The list columns for table output: --columns, else the global --fields, else the operation's defaults. */
export function listColumns(c: Context, op: OperationInfo, options: { columns?: string }): Record<string, string[]> | undefined {
const chosen = parseColumnList(options.columns) ?? c.fields
return chosen ? { '*': chosen } : defaultListColumns(op.id)
}
+15 -11
View File
@@ -13,7 +13,7 @@ import type { Command } from 'commander'
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { countFooter, instantText, positiveInt, type Json } from './shared.js'
import { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
import type { DisplayOptions } from '@qrun-io/qqq-core'
const TARGET_TYPES = ['TABLE', 'APP', 'RECORD', 'PROCESS', 'REPORT', 'SAVED_VIEW']
@@ -115,10 +115,11 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
.description('Comment threads by latest activity, replies under their thread.')
.option('-l, --limit <n>', 'threads per page (1-50)', '20')
.option('--cursor <token>', 'continue from a previous page')
.action(async (table: string, pk: string, options: { limit: string; cursor?: string }) => {
.option(...COLUMNS_OPTION)
.action(async (table: string, pk: string, options: { limit: string; cursor?: string; columns?: string }) => {
const c = ctx()
const page = await (await collab(c)).comments(table, pk, { limit: positiveInt(options.limit, '--limit'), cursor: options.cursor })
c.print({ data: page, rows: commentRows(page.threads, c.dateTimeDisplay), columns: ['id', 'author', 'at', 'state', 'body'], footer: countFooter(page.threads.length, 'thread', [page.nextCursor && `more: --cursor ${page.nextCursor}`]) })
c.print({ data: page, ...chooseColumns(options.columns, c.fields, ['id', 'author', 'at', 'state', 'body'], commentRows(page.threads, c.dateTimeDisplay), page.threads), footer: countFooter(page.threads.length, 'thread', [page.nextCursor && `more: --cursor ${page.nextCursor}`]) })
})
comments.command('add <table> <pk> <body...>')
.description('Post a comment (mentions: @[Name](user:id)); --reply-to answers a thread.')
@@ -131,10 +132,10 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
})
const favorites = noun.command('favorites').description('Your favorites (starred tables, records, apps, processes, reports, saved views).')
favorites.command('list').description('Your favorites in order.').action(async () => {
favorites.command('list').description('Your favorites in order.').option(...COLUMNS_OPTION).action(async (options: { columns?: string }) => {
const c = ctx()
const items = await (await collab(c)).favorites()
c.print({ data: items, rows: favoriteRows(items), columns: ['id', 'type', 'name', 'record', 'label', 'folder', 'available'] })
c.print({ data: items, ...chooseColumns(options.columns, c.fields, ['id', 'type', 'name', 'record', 'label', 'folder', 'available'], favoriteRows(items), items) })
})
favorites.command('add <name> [recordId]')
.description('Star a record (`add person 1`), a table (`add person`), or --type APP|PROCESS|REPORT|SAVED_VIEW.')
@@ -170,10 +171,11 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
tags.command('list <table> [pk]')
.description('The tag vocabulary of a table (search with -q), or the tags on one record.')
.option('-q, --query <text>', 'search the vocabulary')
.action(async (table: string, pk: string | undefined, options: { query?: string }) => {
.option(...COLUMNS_OPTION)
.action(async (table: string, pk: string | undefined, options: { query?: string; columns?: string }) => {
const c = ctx()
const items = pk !== undefined ? await (await collab(c)).recordTags(table, pk) : await (await collab(c)).tags(table, options.query)
c.print({ data: items, rows: tagRows(items), columns: ['id', 'label', 'color', 'scope', 'approved', 'used'] })
c.print({ data: items, ...chooseColumns(options.columns, c.fields, ['id', 'label', 'color', 'scope', 'approved', 'used'], tagRows(items), items) })
})
tags.command('add <table> <pk> <tags...>')
.description('Put tags on a record (ids or labels).')
@@ -201,10 +203,11 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
noun.command('recents')
.description('What you viewed recently, newest first.')
.option('-l, --limit <n>', 'most entries (1-50)', '20')
.action(async (options: { limit: string }) => {
.option(...COLUMNS_OPTION)
.action(async (options: { limit: string; columns?: string }) => {
const c = ctx()
const items = await (await collab(c)).recents(positiveInt(options.limit, '--limit'))
c.print({ data: items, rows: favoriteRows(items), columns: ['type', 'name', 'record', 'label', 'available'] })
c.print({ data: items, ...chooseColumns(options.columns, c.fields, ['type', 'name', 'record', 'label', 'available'], favoriteRows(items), items) })
})
const followRows = (state: { following: boolean; follow?: Json | null }) => [{ following: state.following, notifyOnChange: state.follow?.notifyOnChange ?? '', notifyOnComment: state.follow?.notifyOnComment ?? '', reason: state.follow?.reason ?? '' }]
@@ -249,9 +252,10 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
noun.command('people [query]')
.description('Search the people directory by name or email.')
.option('-l, --limit <n>', 'most people (1-100)', '20')
.action(async (query: string | undefined, options: { limit: string }) => {
.option(...COLUMNS_OPTION)
.action(async (query: string | undefined, options: { limit: string; columns?: string }) => {
const c = ctx()
const people = await (await collab(c)).people(query, positiveInt(options.limit, '--limit'))
c.print({ data: people, rows: people.map((p) => ({ user: p.userId ?? '', name: p.displayName ?? '', email: p.email ?? '', lastSeen: instantText(p.lastSeenAt ?? '', c.dateTimeDisplay) })), columns: ['user', 'name', 'email', 'lastSeen'] })
c.print({ data: people, ...chooseColumns(options.columns, c.fields, ['user', 'name', 'email', 'lastSeen'], people.map((p) => ({ user: p.userId ?? '', name: p.displayName ?? '', email: p.email ?? '', lastSeen: instantText(p.lastSeenAt ?? '', c.dateTimeDisplay) })), people) })
})
}
+1
View File
@@ -63,6 +63,7 @@ export function addOperationCommands(noun: Command, extension: string, pathPrefi
if (op.bodyTypes.includes('application/json') || op.bodyTypes.length === 0) cmd.option('-b, --body-json <json|file|->', 'JSON request body (inline, a file, or - for stdin)')
if (op.bodyTypes.includes('multipart/form-data')) cmd.option('-F, --form <name=value|name=@file...>', 'multipart form fields; @file uploads a file')
cmd.option('-o, --out <file>', 'write a binary response to a file (- for stdout)')
cmd.option('--columns <names>', 'table output: the columns of the result\'s lists, comma-separated (nested fields as a.b)')
cmd.action(async (...actionArgs: unknown[]) => {
const values = actionArgs.slice(0, op.pathParams.length) as (string | undefined)[]
const options = actionArgs[op.pathParams.length] as OperationOptions
+14 -1
View File
@@ -8,7 +8,7 @@ import { Option } from 'commander'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chipAnsi, deniedTableReason, displayDateTime, type TableAction, readFilePreferences, safeFileName, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
import { chipAnsi, deniedTableReason, parseColumnList, displayDateTime, type TableAction, readFilePreferences, safeFileName, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { UsageError } from '../context.js'
@@ -134,6 +134,19 @@ export function orderFrom(note: (text: string) => void, options: { order?: strin
return order.length ? order : undefined
}
/**
* The columns of a list command: --columns (or the global --fields), else its defaults. A chosen
* column the prepared rows do not have is read from the item as the server sent it.
*/
export function chooseColumns(given: string | undefined, fields: string[] | undefined, defaults: string[], rows: Json[], items: readonly unknown[]): { rows: Json[]; columns: string[] } {
const chosen = parseColumnList(given) ?? fields
if (!chosen) return { rows, columns: defaults }
return { rows: rows.map((row, i) => ({ ...(items[i] as Json | undefined), ...row })), columns: chosen }
}
/** The --columns option of list commands. */
export const COLUMNS_OPTION = ['--columns <names>', 'columns to show, comma-separated (also any field of the item as the server sends it)'] as const
/** The footer every list ends with: `(3 records; joined: pet; more may exist: …)`. */
export function countFooter(n: number, singular: string, parts: Array<string | number | false | undefined | null> = [], pluralForm?: string): string {
return `(${[plural(n, singular, pluralForm), ...parts.filter(Boolean)].join('; ')})`
+3 -1
View File
@@ -22,6 +22,8 @@ export const io = {
out: (text: string): void => { process.stdout.write(text) },
err: (text: string): void => { process.stderr.write(text) },
isTTY: (): boolean => Boolean(process.stdout.isTTY),
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
}
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
@@ -88,7 +90,7 @@ export class Context {
/** Prints a result in the chosen format, after --query. */
print(printable: Printable): void {
io.out(render(printable, this.format, this.options.query) + '\n')
io.out(render(printable, this.format, this.options.query, { width: io.width() }) + '\n')
}
/** Reshapes data with --query without printing (for callers that print themselves). */
+5 -1
View File
@@ -110,6 +110,8 @@ export interface RunCliIo {
stderr: (text: string) => void
/** Whether the output may use terminal formatting (default false: table output is still chosen by --output). */
isTTY?: boolean
/** The width nested results are fitted to in table output (default: none). */
width?: number
}
/**
@@ -118,10 +120,11 @@ export interface RunCliIo {
* would ask need their values or --yes. One run at a time.
*/
export async function runCli(argv: string[], options: RunCliIo): Promise<number> {
const saved = { out: io.out, err: io.err, isTTY: io.isTTY, stdoutWrite: process.stdout.write, stderrWrite: process.stderr.write, stdinTTY: Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') }
const saved = { out: io.out, err: io.err, isTTY: io.isTTY, width: io.width, stdoutWrite: process.stdout.write, stderrWrite: process.stderr.write, stdinTTY: Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') }
io.out = options.stdout
io.err = options.stderr
io.isTTY = () => options.isTTY ?? false
io.width = () => options.width
// a few commands write to the process streams directly; capture those too while the command runs
process.stdout.write = ((chunk: string | Uint8Array) => { options.stdout(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stdout.write
process.stderr.write = ((chunk: string | Uint8Array) => { options.stderr(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stderr.write
@@ -140,6 +143,7 @@ export async function runCli(argv: string[], options: RunCliIo): Promise<number>
io.out = saved.out
io.err = saved.err
io.isTTY = saved.isTTY
io.width = saved.width
process.stdout.write = saved.stdoutWrite
process.stderr.write = saved.stderrWrite
if (saved.stdinTTY) Object.defineProperty(process.stdin, 'isTTY', saved.stdinTTY)
@@ -0,0 +1,40 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Default columns for the lists the common extension operations return (the alerts inbox,
* notebooks, SQL history, comments, tasks, favorites, recents), so their table output shows what a
* person scans for rather than every key. Keys are operation ids; values map the list's key path
* in the response to its columns (see DocumentLayout.columns). A `--columns` choice replaces them.
*/
const INBOX = ['id', 'severity', 'title', 'category', 'age', 'unread']
const TASKS = ['id', 'title', 'status', 'assigneeDisplayName', 'dueAt', 'tableName', 'recordId']
const TARGETS = ['id', 'targetType', 'targetName', 'recordId', 'label', 'folder', 'available']
export const DEFAULT_LIST_COLUMNS: Readonly<Record<string, Readonly<Record<string, string[]>>>> = {
alertsInbox: { items: INBOX },
alertsInboxSummary: { items: INBOX, banners: INBOX },
sqlWorkbenchListNotebooks: { notebooks: ['id', 'title', 'visibility', 'ownerName', 'defaultBackendName', 'currentVersion', 'modifyDate'] },
sqlWorkbenchGetHistory: { executions: ['startTime', 'backendName', 'status', 'durationMs', 'rowsReturned', 'sqlText'] },
getQbitCollaborationRecordsByTableByPkComments: { threads: ['id', 'authorDisplayName', 'createdAt', 'resolved', 'body'] },
getQbitCollaborationRecordsByTableByPkTasks: { items: TASKS },
getQbitCollaborationTasksMine: { items: TASKS },
getQbitCollaborationMyWork: { tasks: TASKS },
getQbitCollaborationFavorites: { items: TARGETS },
getQbitCollaborationRecents: { items: TARGETS },
}
/** The default list columns of an operation's response, or undefined (every key is shown). */
export function defaultListColumns(operationId: string): Record<string, string[]> | undefined {
const columns = DEFAULT_LIST_COLUMNS[operationId]
return columns ? { ...columns } : undefined
}
/** `a, b,c` as `['a', 'b', 'c']`; undefined for nothing. */
export function parseColumnList(text: string | undefined): string[] | undefined {
const list = (text ?? '').split(',').map((c) => c.trim()).filter(Boolean)
return list.length ? list : undefined
}
+1
View File
@@ -42,6 +42,7 @@ export * from './render/files.js'
export * from './render/text.js'
export * from './extensions/registry.js'
export * from './extensions/collaboration.js'
export * from './extensions/list-columns.js'
export * from './records/developer.js'
export * from './records/sharing.js'
export * from './bulk/mapping.js'
+161 -14
View File
@@ -31,6 +31,29 @@ export interface Printable {
headers?: string[]
/** A trailer for table output (counts, hints). */
footer?: string
/**
* Lays out a nested result for people (table output): scalars as key/value rows, each list of
* objects as a sub-table. Given, the layout is always used; without it, only for results with
* nested lists or objects and no `rows`.
*/
layout?: DocumentLayout
}
/** How the table output lays out a nested result (see {@link formatDocument}). */
export interface DocumentLayout {
/**
* Columns per list, by the list's key path (`items`, `data.rows`; `''` for a top-level list);
* `'*'` applies to every list without its own choice. Default: the union of the row keys.
*/
columns?: Record<string, string[]>
/** The width to fit tables in (the terminal's columns); none: only the 60-character cell cap. */
width?: number
}
/** Options of {@link render} that come from the terminal rather than the command. */
export interface RenderOptions {
/** The terminal width; table output is fitted to it (cells cut with …, columns that do not fit dropped). */
width?: number
}
/** Applies a JMESPath expression to data (`--query`); `undefined` leaves the data unchanged. */
@@ -68,6 +91,49 @@ export function chipAnsi(text: string, color: string): string {
return code ? `\x1b[${code}m${text}\x1b[0m` : text
}
/** The columns of a table that fit a width: how wide each shown column is, and how many are shown. */
export function fitColumns(natural: number[], width: number | undefined, gap = 2): { widths: number[]; shown: number } {
if (!width || width <= 0) return { widths: [...natural], shown: natural.length }
const minimum = natural.map((w) => Math.min(w, 8))
// as many columns as fit at their minimum width, then the widest shrink until the row fits
let shown = 0
let used = 0
while (shown < natural.length && used + (shown ? gap : 0) + minimum[shown]! <= width) used += (shown ? gap : 0) + minimum[shown++]!
shown = Math.max(shown, 1)
const widths = natural.slice(0, shown)
let total = widths.reduce((a, b) => a + b, 0) + gap * (shown - 1)
while (total > width) {
let widest = -1
for (let i = 0; i < shown; i++) if (widths[i]! > minimum[i]! && (widest < 0 || widths[i]! > widths[widest]!)) widest = i
if (widest < 0) break
widths[widest]!--
total--
}
return { widths, shown }
}
/** A table laid out in a width: its text, and the columns left out because they did not fit. */
export function layoutTable(rows: Row[], columns: string[], headers: string[] = columns, options: { maxCell?: number; width?: number; cell?: (value: unknown) => string } = {}): { text: string; hidden: string[] } {
const maxCell = options.maxCell ?? 60
const toText = options.cell ?? cellText
// ANSI color (a CHIP on a terminal) takes no width
const visible = (text: string) => text.replace(ANSI, '').length
const cut = (text: string, width: number) => {
if (visible(text) <= width) return text
const plain = text.replace(ANSI, '')
return width <= 1 ? plain.slice(0, width) : `${plain.slice(0, width - 1)}…`
}
const cells = rows.map((r) => columns.map((c) => cut(toText(r[c]).replace(/\s+/g, ' '), maxCell)))
const natural = columns.map((c, i) => Math.max((headers[i] ?? c).length, ...cells.map((row) => visible(row[i]!))))
const { widths, shown } = fitColumns(natural, options.width)
const line = (values: string[]) => values.slice(0, shown).map((v, i) => {
const text = cut(v, widths[i]!)
return text + ' '.repeat(Math.max(0, widths[i]! - visible(text)))
}).join(' ').trimEnd()
const text = [line(columns.map((c, i) => headers[i] ?? c)), line(widths.map((w) => '-'.repeat(w))), ...cells.map(line)].join('\n')
return { text, hidden: columns.slice(shown) }
}
/**
* Formats rows as an aligned text table with a header rule; wide cells are cut with an ellipsis.
*
@@ -75,19 +141,93 @@ export function chipAnsi(text: string, color: string): string {
* @param columns - Column keys.
* @param headers - Header labels (default: the keys).
* @param maxCell - Longest cell before cutting (default 60).
* @param width - Fit the table in this width (columns that do not fit are left out).
*/
export function formatTable(rows: Row[], columns: string[], headers: string[] = columns, maxCell = 60): string {
// ANSI color (a CHIP on a terminal) takes no width
const visible = (text: string) => text.replace(ANSI, '').length
const cell = (v: unknown) => {
const text = cellText(v).replace(/\s+/g, ' ')
if (visible(text) <= maxCell) return text
const plain = text.replace(ANSI, '')
return `${plain.slice(0, maxCell - 1)}…`
export function formatTable(rows: Row[], columns: string[], headers: string[] = columns, maxCell = 60, width?: number): string {
return layoutTable(rows, columns, headers, { maxCell, width }).text
}
function isPlainObject(value: unknown): value is Row {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isObjectList(value: unknown): value is Row[] {
return Array.isArray(value) && value.length > 0 && value.every(isPlainObject)
}
/** Whether a result has nested lists or objects that {@link formatDocument} shows better than key/value rows of JSON. */
export function isNestedDocument(data: unknown): boolean {
if (isObjectList(data)) return data.some((row) => Object.values(row).some((v) => isPlainObject(v) || isObjectList(v)))
return isPlainObject(data) && Object.values(data).some((v) => isPlainObject(v) || isObjectList(v))
}
/** A list in one cell: scalars joined; objects by their label or name, else counted. */
function listText(list: unknown[]): string {
if (!list.length) return ''
if (list.every(isPlainObject)) {
const names = list.map((o) => o.label ?? o.name ?? o.title)
return names.every((n) => typeof n === 'string' || typeof n === 'number') ? names.join(', ') : `${list.length} item${list.length === 1 ? '' : 's'}`
}
const widths = columns.map((c, i) => Math.max((headers[i] ?? c).length, ...rows.map((r) => visible(cell(r[c])))))
const line = (cells: string[]) => cells.map((c, i) => c + ' '.repeat(Math.max(0, (widths[i] ?? 0) - visible(c)))).join(' ').trimEnd()
return [line(headers), line(widths.map((w) => '-'.repeat(w))), ...rows.map((r) => line(columns.map((c) => cell(r[c]))))].join('\n')
return list.map(cellText).join(', ')
}
function documentCell(value: unknown): string {
return Array.isArray(value) ? listText(value) : cellText(value)
}
/** A row with its nested objects flattened one level (`assignee: {name}` -> `assignee.name`). */
function flattenRow(row: Row): Row {
const flat: Row = {}
for (const [key, value] of Object.entries(row)) {
if (isPlainObject(value) && Object.keys(value).length) for (const [inner, v] of Object.entries(value)) flat[`${key}.${inner}`] = v
else flat[key] = value
}
return flat
}
function listTable(list: Row[], path: string, layout: DocumentLayout): string {
const rows = list.map(flattenRow)
const chosen = layout.columns?.[path] ?? layout.columns?.['*']
const columns = chosen?.length ? chosen : columnsOf(rows, {} as Printable)
// a chosen column may name a nested object as a whole (`assignee`): read it from the original row
const shown = rows.map((row, i) => Object.fromEntries(columns.map((c) => [c, c in row ? row[c] : list[i]![c]])))
const { text, hidden } = layoutTable(shown, columns, columns, { width: layout.width, cell: documentCell })
return hidden.length ? `${text}\n(${hidden.length} more column${hidden.length === 1 ? '' : 's'} did not fit: ${hidden.join(', ')}; --columns chooses, --json shows everything)` : text
}
/**
* Lays out a result for people: scalars as aligned key/value rows (nested objects as dotted
* keys), and each list of objects as a sub-table under its key, its columns the union of the row
* keys in first-seen order (or the layout's choice), nested objects flattened one level with
* dotted keys, fitted to the layout's width.
*/
export function formatDocument(data: unknown, layout: DocumentLayout = {}): string {
if (Array.isArray(data)) return isObjectList(data) ? listTable(data, '', layout) : data.length ? data.map(cellText).join('\n') : '(none)'
if (!isPlainObject(data)) return cellText(data)
const pairs: Array<[string, string]> = []
const lists: Array<[string, Row[]]> = []
const walk = (object: Row, prefix: string, depth: number) => {
for (const [name, value] of Object.entries(object)) {
const key = prefix ? `${prefix}.${name}` : name
if (isObjectList(value)) lists.push([key, value])
else if (isPlainObject(value) && Object.keys(value).length && depth < 3) walk(value, key, depth + 1)
else if (Array.isArray(value)) pairs.push([key, value.length ? listText(value) : '(none)'])
else pairs.push([key, cellText(value)])
}
}
walk(data, '', 1)
const keyWidth = Math.max(0, ...pairs.map(([k]) => k.length))
const valueWidth = layout.width ? Math.max(8, layout.width - keyWidth - 2) : 60
const blocks: string[] = []
if (pairs.length) {
blocks.push(pairs.map(([key, value]) => {
const text = value.replace(/\s+/g, ' ')
const shown = text.length > valueWidth ? `${text.slice(0, valueWidth - 1)}…` : text
return `${key.padEnd(keyWidth)} ${shown}`.trimEnd()
}).join('\n'))
}
for (const [key, list] of lists) blocks.push(`${key} (${list.length})\n${listTable(list, key, layout)}`)
return blocks.join('\n\n')
}
/** CSV with RFC 4180 quoting and a header row. */
@@ -105,10 +245,13 @@ export function formatText(rows: Row[], columns: string[]): string {
/**
* Renders a result in a format. `query` (JMESPath) is applied first and, when it reshapes the data
* away from the rows the command prepared, the table is rebuilt from the reshaped data.
* away from the rows the command prepared, the table is rebuilt from the reshaped data. Table
* output of a nested result (or of any result given a `layout`) is laid out by
* {@link formatDocument}.
*/
export function render(printable: Printable, format: OutputFormat, query?: string): string {
const shaped: Printable = query ? { data: applyQuery(printable.data, query) } : printable
export function render(printable: Printable, format: OutputFormat, query?: string, options: RenderOptions = {}): string {
// a query reshapes the data: the command's rows and column choices no longer apply
const shaped: Printable = query ? { data: applyQuery(printable.data, query), ...(printable.layout ? { layout: {} } : {}) } : printable
switch (format) {
case 'json':
return JSON.stringify(shaped.data, null, 2)
@@ -125,6 +268,10 @@ export function render(printable: Printable, format: OutputFormat, query?: strin
}
case 'table':
default: {
if (!shaped.rows && (shaped.layout || isNestedDocument(shaped.data))) {
const text = formatDocument(shaped.data, { ...shaped.layout, width: options.width ?? shaped.layout?.width })
return shaped.footer ? `${text}\n${shaped.footer}` : text
}
const rows = rowsOf(shaped)
const columns = columnsOf(rows, shaped)
const table = formatTable(rows, columns, shaped.headers ?? columns)
+92
View File
@@ -0,0 +1,92 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The document layout of table output (extension results): scalars as key/value rows, lists of
* objects as sub-tables, fitted to a width. The layouts are compared with src/test/golden/*.txt;
* UPDATE_GOLDEN=1 rewrites them.
*/
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { fitColumns, formatDocument, isNestedDocument, render } from '../render/output.js'
import { DEFAULT_LIST_COLUMNS, defaultListColumns, parseColumnList } from '../extensions/list-columns.js'
const GOLDEN = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'test', 'golden')
function golden(name: string, actual: string): void {
const file = join(GOLDEN, `${name}.txt`)
if (process.env.UPDATE_GOLDEN === '1' || !existsSync(file)) {
mkdirSync(GOLDEN, { recursive: true })
writeFileSync(file, `${actual}\n`)
if (process.env.UPDATE_GOLDEN !== '1') assert.fail(`wrote missing golden ${name}.txt; check it and run again`)
return
}
assert.equal(`${actual}\n`, readFileSync(file, 'utf8').replace(/\r\n/g, '\n'), `golden ${name}.txt (UPDATE_GOLDEN=1 rewrites it)`)
}
const INBOX = {
items: [
{ id: 7, title: 'Send Test Event To Webhook failed', severity: 'ERROR', category: 'JOB', age: '4 min', unread: true, href: '/app/webhook', actions: [{ label: 'Mark read', name: 'read' }, { label: 'Dismiss', name: 'dismiss' }] },
{ id: 6, title: 'Calculate Order Total (JavaScript) failed', severity: 'ERROR', category: 'JOB', age: '7 min', unread: false, href: null, actions: [] },
],
total: 2,
offset: 0,
limit: 25,
}
const CAPABILITIES = {
version: '1.0.0',
canWrite: false,
limits: { maxRows: 10000, pageSize: 500 },
dialects: ['h2', 'postgres'],
backends: [{ name: 'h2', vendor: 'h2', owner: { id: 1, name: 'Admin' }, schemas: ['PUBLIC', 'INFO'] }],
notebooks: [],
}
test('a result with lists: key/value rows, then one sub-table per list (union of keys, first-seen order)', () => {
golden('document-inbox', formatDocument(INBOX))
golden('document-capabilities', formatDocument(CAPABILITIES))
})
test('sub-tables fit the width: cells cut with …, columns that do not fit are named', () => {
golden('document-inbox-narrow', formatDocument(INBOX, { width: 50 }))
})
test('a layout chooses columns per list; * applies to every list; a nested object reads as a.b', () => {
golden('document-inbox-columns', formatDocument(INBOX, { columns: defaultListColumns('alertsInbox') }))
golden('document-capabilities-columns', formatDocument(CAPABILITIES, { columns: { '*': ['name', 'owner.name', 'schemas'] } }))
golden('document-top-level-list', formatDocument(INBOX.items, { columns: { '': ['id', 'title'] }, width: 30 }))
})
test('render uses the document layout for nested results and layouts; json and csv do not change', () => {
assert.equal(isNestedDocument(INBOX), true)
assert.equal(isNestedDocument({ a: 1, b: ['x'] }), false)
assert.equal(render({ data: INBOX }, 'table'), formatDocument(INBOX))
assert.equal(render({ data: { notebooks: [] }, layout: {} }, 'table'), 'notebooks (none)')
// flat results keep the key/value table
assert.equal(render({ data: { a: 1 } }, 'table'), 'key value\n--- -----\na 1')
assert.equal(render({ data: INBOX, layout: {} }, 'json'), JSON.stringify(INBOX, null, 2))
// --query reshapes: the layout's column choice no longer applies, the width still does
assert.equal(render({ data: INBOX, layout: { columns: { '*': ['id'] } } }, 'table', 'items[].{id: id, age: age}', { width: 40 }), 'id age\n-- -----\n7 4 min\n6 7 min')
assert.equal(render({ data: INBOX, footer: '(2 alerts)' }, 'table').endsWith('\n(2 alerts)'), true)
})
test('fitColumns keeps as many columns as fit, then shrinks the widest', () => {
assert.deepEqual(fitColumns([10, 20, 30], undefined), { widths: [10, 20, 30], shown: 3 })
assert.deepEqual(fitColumns([10, 20, 30], 40), { widths: [10, 13, 13], shown: 3 })
assert.deepEqual(fitColumns([10, 20, 30, 30], 30), { widths: [8, 9, 9], shown: 3 })
assert.deepEqual(fitColumns([3, 4], 100), { widths: [3, 4], shown: 2 })
})
test('default list columns cover the common extension lists; --columns parses', () => {
for (const id of ['alertsInbox', 'sqlWorkbenchListNotebooks', 'sqlWorkbenchGetHistory', 'getQbitCollaborationRecordsByTableByPkComments', 'getQbitCollaborationTasksMine', 'getQbitCollaborationFavorites', 'getQbitCollaborationRecents']) assert.ok(DEFAULT_LIST_COLUMNS[id], id)
assert.equal(defaultListColumns('nope'), undefined)
assert.deepEqual(parseColumnList(' id, title ,,'), ['id', 'title'])
assert.equal(parseColumnList(''), undefined)
})
@@ -0,0 +1,11 @@
version 1.0.0
canWrite false
limits.maxRows 10000
limits.pageSize 500
dialects h2, postgres
notebooks (none)
backends (1)
name owner.name schemas
---- ---------- ------------
h2 Admin PUBLIC, INFO
@@ -0,0 +1,11 @@
version 1.0.0
canWrite false
limits.maxRows 10000
limits.pageSize 500
dialects h2, postgres
notebooks (none)
backends (1)
name vendor owner.id owner.name schemas
---- ------ -------- ---------- ------------
h2 h2 1 Admin PUBLIC, INFO
@@ -0,0 +1,9 @@
total 2
offset 0
limit 25
items (2)
id severity title category age unread
-- -------- ----------------------------------------- -------- ----- ------
7 ERROR Send Test Event To Webhook failed JOB 4 min true
6 ERROR Calculate Order Total (JavaScript) failed JOB 7 min false
@@ -0,0 +1,10 @@
total 2
offset 0
limit 25
items (2)
id title severity category age unread
-- ----------- -------- -------- ----- ------
7 Send Test … ERROR JOB 4 min true
6 Calculate … ERROR JOB 7 min false
(2 more columns did not fit: href, actions; --columns chooses, --json shows everything)
@@ -0,0 +1,9 @@
total 2
offset 0
limit 25
items (2)
id title severity category age unread href actions
-- ----------------------------------------- -------- -------- ----- ------ ------------ ------------------
7 Send Test Event To Webhook failed ERROR JOB 4 min true /app/webhook Mark read, Dismiss
6 Calculate Order Total (JavaScript) failed ERROR JOB 7 min false
@@ -0,0 +1,4 @@
id title
-- --------------------------
7 Send Test Event To Webhoo…
6 Calculate Order Total (Ja…