Skip to content

Commit b97987e

Browse files
fix(cli): make tables rows query show the rows
Three things stacked up so the command appeared to do nothing. A row's cells live under `data`, and column inference skips object-valued fields — so the table came back listing an id and two timestamps per row and none of the content the query was run for. `expand` names the wrapper whose keys become columns, unioned across the page like the top-level ones. A cell key that shadows a top-level field is shown by its full path, so two different values never share a header. A cell containing a newline pushed the rest of its row onto the next line and every column after it lost alignment; in text mode a tab invented a field that `cut -f` reads as real. Display cells are now flattened to one line. `sanitize` still keeps \t and \n — json and yaml must round-trip them, and this is applied only to finished cells. A single cell holding an LLM response set the column width for the whole table and pushed everything after it off-screen, so table cells clamp at 60 columns. text/json/yaml are untouched: those exist for the whole value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent 5154a92 commit b97987e

6 files changed

Lines changed: 174 additions & 13 deletions

File tree

packages/sim-cli/src/contract/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export const CLI_CONTRACT: CliContract = {
9898
queryRows: {
9999
command: 'tables rows query',
100100
flags: { predicate: { name: 'filter', json: true }, sort: { json: true } },
101+
// A row's cells live under `data`; without this the table showed an id and
102+
// two timestamps per row and none of the content anyone ran the query for.
103+
expand: 'data',
101104
},
102105

103106
// ─── Output columns for list commands ─────────────────────────────────────

packages/sim-cli/src/contract/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ export interface CommandSpec {
8484
* the point is that the caller can tell whether they meant it.
8585
*/
8686
confirm?: string
87+
/**
88+
* Discover table columns from inside this nested field as well as from the
89+
* row's own scalars.
90+
*
91+
* For rows whose real content sits in a wrapper the server chose — a table
92+
* row's user-defined cells live under `data` — the inferred columns would
93+
* otherwise be `id` and two timestamps, because a nested object cannot be a
94+
* column. Only meaningful when `columns` is absent.
95+
*/
96+
expand?: string
8797
/**
8898
* The response IS a document, not a record to look at.
8999
*

packages/sim-cli/src/output/render.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,61 @@ describe('sanitize', () => {
259259
expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22')
260260
})
261261
})
262+
263+
describe('cells stay on their own line', () => {
264+
const rows = [{ note: 'first\nsecond', tabbed: 'a\tb' }]
265+
const columns: Column<(typeof rows)[number]>[] = [
266+
{ header: 'note', value: (row) => row.note },
267+
{ header: 'tabbed', value: (row) => row.tabbed },
268+
]
269+
270+
function captured(format: 'table' | 'text' | 'json'): string[] {
271+
const lines: string[] = []
272+
const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => {
273+
lines.push(line)
274+
})
275+
printList(format, rows, columns)
276+
spy.mockRestore()
277+
return lines
278+
}
279+
280+
it('collapses a newline inside a table cell', () => {
281+
// One newline pushed the rest of the row onto the next line and every
282+
// column after it lost its alignment.
283+
const table = captured('table').join('\n')
284+
expect(table.split('\n')).toHaveLength(2)
285+
expect(table).toContain('first second')
286+
})
287+
288+
it('collapses a tab in text mode, so cut -f still sees real fields', () => {
289+
const [line] = captured('text')
290+
expect(line.split('\t')).toHaveLength(2)
291+
expect(line).toBe('first second\ta b')
292+
})
293+
294+
it('leaves json untouched', () => {
295+
expect(JSON.parse(captured('json').join('\n'))).toEqual([
296+
{ note: 'first\nsecond', tabbed: 'a\tb' },
297+
])
298+
})
299+
300+
it('clamps a very wide cell in table mode only', () => {
301+
const wide = [{ blob: 'x'.repeat(500) }]
302+
const cols: Column<(typeof wide)[number]>[] = [{ header: 'blob', value: (row) => row.blob }]
303+
const lines: string[] = []
304+
const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => {
305+
lines.push(line)
306+
})
307+
printList('table', wide, cols)
308+
printList('text', wide, cols)
309+
spy.mockRestore()
310+
311+
// The table arrives as one string: header line, then the clamped body line.
312+
const [header, body] = lines[0].split('\n')
313+
expect(header.trim()).toBe('BLOB')
314+
expect(body).toMatch(/$/)
315+
expect(body.length).toBeLessThan(100)
316+
// `text` feeds pipelines; truncating there would corrupt the data.
317+
expect(lines[1]).toHaveLength(500)
318+
})
319+
})

packages/sim-cli/src/output/render.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,49 @@ function pad(value: string, width: number): string {
131131
return value + ' '.repeat(Math.max(0, width - visibleWidth(value)))
132132
}
133133

134+
/**
135+
* Flattens a cell onto one line.
136+
*
137+
* `sanitize` keeps `\t` and `\n` on purpose — they are legitimate content, and
138+
* json/yaml must round-trip them. Every *display* format is line-oriented
139+
* though: one newline inside a table cell pushes the rest of the row into the
140+
* next line and every column after it loses its alignment, and in `text` mode a
141+
* stray tab invents a field that `cut -f` then reads as real. A table row of a
142+
* workflow's Slack output did exactly this.
143+
*
144+
* Applied to finished cells only, so it cannot reach the machine formats.
145+
*/
146+
function oneLine(value: string): string {
147+
return value.replace(/\s*[\r\n\t]+\s*/g, ' ')
148+
}
149+
150+
/**
151+
* Widest a single table column may render.
152+
*
153+
* A table row can hold a whole LLM response; at full width one such cell sets
154+
* the column width for every row and pushes everything after it off-screen.
155+
* `text`, `json` and `yaml` are untouched — this is a legibility cap on the
156+
* human view, and the other three formats exist for the whole value.
157+
*/
158+
const MAX_CELL_WIDTH = 60
159+
160+
function clampCell(value: string): string {
161+
// ANSI-bearing cells come from the short formatters (`yes`/`no`, the empty
162+
// glyph); slicing one mid-escape would corrupt it, and none are ever wide.
163+
if (visibleWidth(value) <= MAX_CELL_WIDTH || value !== value.replace(ANSI_PATTERN, '')) {
164+
return value
165+
}
166+
return `${value.slice(0, MAX_CELL_WIDTH - 1)}…`
167+
}
168+
134169
function renderTable<T>(rows: T[], columns: Column<T>[]): string {
135170
if (rows.length === 0) return chalk.dim('No results.')
136171

137172
// A header can be a user-defined column name (a table's own columns), so it is
138173
// remote content and gets the same treatment as a cell. Doing it here rather
139174
// than only at each call site means a future column source cannot reopen this.
140175
const headers = columns.map((column) => sanitize(column.header))
141-
const cells = rows.map((row) => columns.map((column) => column.value(row)))
176+
const cells = rows.map((row) => columns.map((column) => clampCell(oneLine(column.value(row)))))
142177
const widths = columns.map((_column, index) =>
143178
Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index])))
144179
)
@@ -193,7 +228,7 @@ export function printList<T>(format: OutputFormat, rows: T[], columns: Column<T>
193228

194229
if (format === 'text') {
195230
for (const row of rows) {
196-
console.log(columns.map((column) => stripAnsi(column.value(row))).join('\t'))
231+
console.log(columns.map((column) => oneLine(stripAnsi(column.value(row)))).join('\t'))
197232
}
198233
return
199234
}
@@ -226,13 +261,13 @@ export function printRecord(format: OutputFormat, fields: Array<[string, string]
226261

227262
if (format === 'text') {
228263
for (const [label, value] of fields) {
229-
console.log(`${label}\t${stripAnsi(value)}`)
264+
console.log(`${label}\t${oneLine(stripAnsi(value))}`)
230265
}
231266
return
232267
}
233268

234269
const width = Math.max(...fields.map(([label]) => label.length))
235270
for (const [label, value] of fields) {
236-
console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${value}`)
271+
console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`)
237272
}
238273
}

packages/sim-cli/src/runtime/build.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,30 @@ describe('pagination slot', () => {
243243
expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' })
244244
})
245245
})
246+
247+
describe('rows whose content sits in a wrapper', () => {
248+
it('discovers columns from the expanded field', async () => {
249+
// `tables rows query` returned a table of ids and timestamps: a row's cells
250+
// live under `data`, and column inference skipped it for being an object.
251+
mockRequest.mockReset()
252+
mockRequest.mockResolvedValue({
253+
data: [
254+
{ id: 'r1', data: { url: 'https://a', title: 'A' }, createdAt: 'now' },
255+
{ id: 'r2', data: { url: 'https://b', extra: 'E' }, createdAt: 'now' },
256+
],
257+
nextCursor: null,
258+
})
259+
const lines: string[] = []
260+
output.format = 'text'
261+
vi.spyOn(console, 'log').mockImplementation((line: string) => {
262+
lines.push(line)
263+
})
264+
await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1'])
265+
output.format = 'json'
266+
267+
// Unioned across the page: `extra` appears only on the second row.
268+
expect(lines[0]).toContain('https://a')
269+
expect(lines[0]).toContain('A')
270+
expect(lines[1]).toContain('E')
271+
})
272+
})

packages/sim-cli/src/runtime/build.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,12 @@ function columnsFrom(specs: ColumnSpec[]): Column<unknown>[] {
8989
* Row shapes are only known at runtime here — a table's `data` is user-defined —
9090
* so the keys are unioned across the page rather than read off the first row,
9191
* which would let a sparse row hide every column it happens to omit. Nested
92-
* values are skipped: they render as JSON blobs and make the table unreadable.
92+
* values are skipped: they render as JSON blobs and make the table unreadable —
93+
* unless the contract names one with `expand`, which is how a row's cells reach
94+
* the table.
9395
*/
94-
function inferColumns(rows: unknown[]): Column<unknown>[] {
95-
const keys: string[] = []
96+
function inferColumns(rows: unknown[], expand?: string): Column<unknown>[] {
97+
const paths: Array<{ path: string; header: string }> = []
9698
const seen = new Set<string>()
9799

98100
for (const row of rows) {
@@ -101,16 +103,34 @@ function inferColumns(rows: unknown[]): Column<unknown>[] {
101103
if (seen.has(key)) continue
102104
if (value !== null && typeof value === 'object') continue
103105
seen.add(key)
104-
keys.push(key)
106+
paths.push({ path: key, header: key })
105107
}
106108
}
107109

108-
return keys.map((key) => ({
110+
// The wrapper named by `expand` holds the only content the caller cares about;
111+
// the loop above skipped it for being an object, which is how `tables rows
112+
// query` came back showing nothing but ids and timestamps.
113+
if (expand) {
114+
const nested = new Set<string>()
115+
for (const row of rows) {
116+
const container = at(row, expand)
117+
if (!container || typeof container !== 'object' || Array.isArray(container)) continue
118+
for (const key of Object.keys(container)) {
119+
if (nested.has(key)) continue
120+
nested.add(key)
121+
// A user-defined key that shadows a top-level one is shown by its full
122+
// path, so two different values never appear under one header.
123+
paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key })
124+
}
125+
}
126+
}
127+
128+
return paths.map(({ path, header }) => ({
109129
// The key itself is remote data when the rows are user-defined, and the
110130
// header is printed just like a cell — sanitizing values but not headers
111131
// left the same control sequences executable one row higher.
112-
header: sanitize(key),
113-
value: (row: unknown) => renderCell(at(row, key), 'auto'),
132+
header: sanitize(header),
133+
value: (row: unknown) => renderCell(at(row, path), 'auto'),
114134
}))
115135
}
116136

@@ -297,7 +317,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
297317
} while (cursor && rows.length < limit)
298318

299319
const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows
300-
printList(profile.output, page, spec.columns ? columnsFrom(spec.columns) : inferColumns(page))
320+
printList(
321+
profile.output,
322+
page,
323+
spec.columns ? columnsFrom(spec.columns) : inferColumns(page, spec.expand)
324+
)
301325
return
302326
}
303327

@@ -318,7 +342,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
318342
if (Array.isArray(data)) {
319343
// Reached when a non-paginated operation answers with a collection.
320344
// `printRecord` would silently print nothing for an array.
321-
printList(profile.output, data, spec.columns ? columnsFrom(spec.columns) : inferColumns(data))
345+
printList(
346+
profile.output,
347+
data,
348+
spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand)
349+
)
322350
return
323351
}
324352

0 commit comments

Comments
 (0)