SLOPSHOPPER

table-preview

Previews a csv, tsv or txt table in a pane: delimiter, columns with simple types, row count and the first 20 rows. Reads only the file you name.

newpanecommand
★ 1v0.1.0MITupdated 2026-10-14samuelbfernandes/moddeck/mods/table-preview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · table-preview
│ ┃ table-preview ✕ › fix the failing auth test and add an audit log call │ ┃ Use /table-preview <path to a .csv, .tsv or │ ┃ .txt file>. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /table-preview │ ⎿ table-preview: table-preview · Use /table-preview <path to a .cs │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · table-preview
Use /table-preview <path to a .csv, .tsv or .txt file>.
README

table-preview

Install: /plugin install table-preview --marketplace samuelbfernandes/moddeck

A quick look at a data table without sending it to the model: columns, types, row count and the first rows, in a pane.

What it does

  • /table-preview <path> reads one .csv, .tsv or .txt file. A relative path is taken from the session's folder; an absolute path is taken as is. Quotes around the path are removed.
  • Detects the delimiter (comma, semicolon or tab) from the first rows.
  • Parses quoted fields properly: delimiters, doubled quotes ("") and line breaks inside quotes are part of the cell.
  • Shows the column count, the header, a simple type per column (number, text or empty) with the share of empty cells, the exact number of data rows (the header not counted) and the first 20 rows as an aligned table (the first 10 columns, cells cut at 18 characters).
  • Warns when the file ends inside a quoted field or when rows have a different number of fields than the header.
  • The pane holds the details; the command's one-line answer (data.csv: 1,204 rows × 7 columns (comma-separated)) is what the model sees. Where nothing draws panes (a headless run), the whole report comes back as text instead.
  • Refuses symbolic links, folders, other file types and files bigger than maxKb, without reading them. It writes nothing and hooks no tool call.

Use

/table-preview data/raw/survey.csv /table-preview "C:\Users\me\My data\counts.tsv"

Turn off

Close the pane, or /plugin disable table-preview. Nothing runs until you call it.

Settings

maxKb (default 2048, at most 4096): the largest file it reads. The engine's file reader cannot read part of a file, so the whole file is read, and a bigger file is refused with a message instead of being cut.

State for the moddeck side bar

{ plugin: 'table-preview', key: 'report' }: the last file read (name, delimiter, rowCount, columnCount, columns, warnings, ...), or null. Nothing is kept in $.store.

Limits

  • Types are a quick guess: a column is number when every non-empty cell looks like a number (a decimal comma counts); empty cells and NA count as empty. Dates, booleans and factors show as text.
  • The first row is always taken as the header.
  • Only the path itself is checked for being a link, not the folders above it.
  • Wide characters (CJK, emoji) can misalign the preview columns.
  • Tested with a mocked engine (claude plugin test), on the terminal and desktop element tables; not yet checked by hand in a live session.

Credit

Adapted from table-preview in awesome-claude-code-mods (MIT, © 2026 Yash Thakker and contributors). Rewritten for moddeck: TypeScript in the house style, semicolon and tab files, a lenient parser that reports an unclosed quote instead of failing, column types and empty shares, an exact row count, a settable size limit, links and folders refused, a text answer where no pane is drawn, and tests. The upstream licence is in LICENSE.

Source 3 files
hooks/register.tsx 98 lines
1// table-preview: `/table-preview <path>` previews a .csv, .tsv or .txt table in a pane: the delimiter (comma, semicolon or tab),
2// the columns with a simple type each (number, text, share empty), the row count and the first 20 rows. It reads only
3// the file named, refuses links, folders and files over `maxKb`, and writes nothing.
4// FAIL OPEN: it hooks no tool call and refuses nothing of Claude's; any problem is a line of text in reply.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import { EXTENSIONS, PREVIEW_COLUMNS, analyse, extOf, reportText, resolveIn, summaryOf, unquote } from './lib'
9
10type Dollar = EngineInterface
11
12const COMMAND = 'table-preview'
13const PANE = 'table-preview'
14const USAGE = 'Use /table-preview <path to a .csv, .tsv or .txt file>.'
15
16const report = atom({ plugin: 'table-preview', key: 'report' } as const, null)
17
18const num = (v: unknown, fallback: number, min: number, max: number): number =>
19  typeof v === 'number' && Number.isFinite(v) && v >= min ? Math.min(max, Math.floor(v)) : fallback
20
21async function run($: Dollar, maxKb: number, args: string): Promise<string> {
22  const path = unquote(args)
23  if (path === '') return `table-preview · ${USAGE}`
24  if (/[\u0000-\u001f\u007f]/.test(path)) return 'table-preview · a path may not hold control characters'
25  if (!EXTENSIONS.has(extOf(path))) return `table-preview · only .csv, .tsv and .txt files are read.\n${USAGE}`
26  const full = resolveIn(await $.session.cwd(), path)
27  let size: number
28  try {
29    const stat = await $.fs.stat(full)
30    if (stat.isLink) return 'table-preview · that path is a symbolic link; name the file it points to instead.'
31    if (stat.kind !== 'file') return 'table-preview · that path is not a file.'
32    size = stat.size
33  } catch {
34    return `table-preview · cannot find ${path.slice(0, 200)}`
35  }
36  if (size > maxKb * 1024) {
37    return `table-preview · ${path.slice(0, 200)} is ${Math.round(size / 1024)} KB, over the ${maxKb} KB limit (the maxKb setting). Nothing was read.`
38  }
39  let text: string
40  try {
41    text = await $.fs.read(full)
42  } catch {
43    return `table-preview · could not read ${path.slice(0, 200)}`
44  }
45  const r = analyse(path, text, size)
46  await update($, report, () => r)
47  const surfaces = await $.session.surfaces().catch(() => [])
48  if (surfaces.length === 0) return reportText(r)
49  const opened = await $.ui.open({ id: PANE, title: r.name.slice(0, 40) || 'Table', columns: 100 })
50
51  return opened.isPlaced ? summaryOf(r) : reportText(r)
52}
53
54export const register: Register = (on, options) => {
55  const maxKb = num(options.maxKb, 2048, 1, 4096)
56
57  on('session.start', async ($, e, next) => {
58    try {
59      await $.command.register({ name: COMMAND, description: 'Preview a csv, tsv or txt table: columns, types, row count, first rows', argumentHint: '<path>' })
60    } catch {
61      // fail-open: the session goes on without the command
62    }
63
64    return next(e)
65  })
66
67  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, maxKb, e.args) })).catch(() => ({
68    text: 'table-preview · something went wrong reading that file',
69  }))
70
71  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
72    const { Box, Code, Text } = $.ui.resolve(e)
73    const r = await read($, report)
74    if (r === null) return <Text dimColor>{USAGE}</Text>
75    const shown = r.columnCount > PREVIEW_COLUMNS ? `the first ${PREVIEW_COLUMNS} of ${r.columnCount} columns` : 'all columns'
76
77    return (
78      <Box flexDirection="column">
79        <Text bold>{summaryOf(r).replace(/^table-preview · /, '')}</Text>
80        {r.warnings.map(w => (
81          <Text color="yellow">{w}</Text>
82        ))}
83        {r.columns.map((c, i) => (
84          <Text wrap="truncate-end">
85            {String(i + 1).padStart(3)}. {c.name} <Text dimColor>· {c.type}{c.emptyPct > 0 ? ` · ${c.emptyPct}% empty` : ''}</Text>
86          </Text>
87        ))}
88        {r.columnCount > r.columns.length && <Text dimColor>+{r.columnCount - r.columns.length} more columns</Text>}
89        <Text dimColor>
90          First {r.previewRows} rows, {shown}:
91        </Text>
92        <Code source={r.preview} wrap="truncate-end" />
93        {r.isCut && <Text dimColor>The preview leaves part of the table out; cells are cut at 18 characters.</Text>}
94      </Box>
95    )
96  })
97}
98
hooks/lib.ts 209 lines
1// table-preview: pure parsing and layout (no engine access), so the tests can call it directly.
2import type { CsvTableColumn, CsvTableReport } from '../types'
3
4export type Delimiter = ',' | ';' | '\t'
5
6export const DELIMITER_NAMES: Record<Delimiter, string> = { ',': 'comma', ';': 'semicolon', '\t': 'tab' }
7export const EXTENSIONS = new Set(['csv', 'tsv', 'txt'])
8/** Rows shown in the preview table. */
9export const PREVIEW_ROWS = 20
10/** Columns shown in the preview table (all of them are listed with their types, up to MAX_LISTED). */
11export const PREVIEW_COLUMNS = 10
12export const MAX_LISTED = 60
13/** Characters of one cell in the preview. */
14export const CELL = 18
15/** The most characters the pane shows of the preview table. */
16export const LIMIT = 9000
17
18export function extOf(path: string): string {
19  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
20  const dot = name.lastIndexOf('.')
21
22  return dot < 0 ? '' : name.slice(dot + 1).toLowerCase()
23}
24
25export function nameOf(path: string): string {
26  return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
27}
28
29export const isAbsolute = (path: string): boolean => /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path)
30
31/** `path` against the session's folder when it is relative, with that folder's own separator (Windows or POSIX). */
32export function resolveIn(cwd: string, path: string): string {
33  if (isAbsolute(path)) return path
34  const sep = cwd.includes('\\') && !cwd.includes('/') ? '\\' : '/'
35
36  return `${cwd.replace(/[\\/]+$/, '')}${sep}${path}`
37}
38
39/** The path as typed, without matching quotes around it. */
40export function unquote(raw: string): string {
41  const path = raw.trim()
42  const m = /^(["'])(.*)\1$/.exec(path)
43
44  return m ? m[2]! : path
45}
46
47/**
48 * RFC 4180 style: fields split on `delimiter`, a field that starts with `"` runs to the closing quote (a doubled `""`
49 * is one quote; delimiters and newlines inside are text). A quote inside an unquoted field is kept as text.
50 * `isOpen` is true when the text ends inside a quoted field. Lines with no characters at all are skipped.
51 */
52export function parse(text: string, delimiter: Delimiter, maxRows = Infinity): { rows: string[][]; isOpen: boolean } {
53  const rows: string[][] = []
54  let row: string[] = []
55  let cell = ''
56  let isQuoted = false
57  let isAtStart = true // at the start of a field
58  let isEmptyLine = true
59  const endRow = () => {
60    row.push(cell)
61    if (!(isEmptyLine && row.length === 1 && cell === '')) rows.push(row)
62    row = []
63    cell = ''
64    isAtStart = true
65    isEmptyLine = true
66  }
67  for (let i = 0; i < text.length && rows.length < maxRows; i++) {
68    const c = text[i]!
69    if (isQuoted) {
70      if (c === '"') {
71        if (text[i + 1] === '"') {
72          cell += '"'
73          i++
74        } else isQuoted = false
75      } else cell += c
76      continue
77    }
78    if (c === '"' && isAtStart) {
79      isQuoted = true
80      isAtStart = false
81      isEmptyLine = false
82    } else if (c === delimiter) {
83      row.push(cell)
84      cell = ''
85      isAtStart = true
86      isEmptyLine = false
87    } else if (c === '\n' || c === '\r') {
88      if (c === '\r' && text[i + 1] === '\n') i++
89      endRow()
90    } else {
91      cell += c
92      isAtStart = false
93      isEmptyLine = false
94    }
95  }
96  if (rows.length < maxRows && (cell !== '' || row.length > 0 || isQuoted)) endRow()
97
98  return { rows, isOpen: isQuoted }
99}
100
101/** Comma, semicolon or tab: the one that splits the first rows into the most columns, the same number in each. */
102export function detectDelimiter(text: string, ext = ''): Delimiter {
103  const head = text.slice(0, 64 * 1024)
104  const order: Delimiter[] = ext === 'tsv' ? ['\t', ',', ';'] : [',', ';', '\t']
105  let best: Delimiter = order[0]!
106  let bestScore = 0
107  for (const d of order) {
108    const { rows } = parse(head, d, 30)
109    const sample = rows.length > 1 && head.length === 64 * 1024 ? rows.slice(0, -1) : rows // the last may be cut
110    const width = sample[0]?.length ?? 0
111    if (width < 2) continue
112    const same = sample.filter(r => r.length === width).length / sample.length
113    const score = width * same * same
114    if (score > bestScore) {
115      best = d
116      bestScore = score
117    }
118  }
119
120  return best
121}
122
123const NUMBER = /^[-+]?(?:\d+(?:[.,]\d*)?|[.,]\d+)(?:[eE][-+]?\d+)?%?$/
124/** Cells counted as empty: nothing, or R's NA. */
125export const isEmptyCell = (cell: string): boolean => {
126  const t = cell.trim()
127
128  return t === '' || t === 'NA'
129}
130
131export function columnsOf(header: string[], data: string[][]): CsvTableColumn[] {
132  const width = data.reduce((most, r) => Math.max(most, r.length), header.length)
133
134  return Array.from({ length: width }, (_, j) => {
135    let empty = 0
136    let numbers = 0
137    for (const r of data) {
138      const cell = r[j] ?? ''
139      if (isEmptyCell(cell)) empty++
140      else if (NUMBER.test(cell.trim())) numbers++
141    }
142    const filled = data.length - empty
143    const type = filled === 0 ? 'empty' : numbers === filled ? 'number' : 'text'
144
145    const name = (header[j] ?? '').replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 80) || `(column ${j + 1})`
146
147    return { name, type, emptyPct: data.length === 0 ? 0 : Math.round((100 * empty) / data.length) }
148  })
149}
150
151const fit = (cell: string, width: number): string => {
152  const flat = cell.replace(/\r?\n/g, '↵').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').replace(/\t/g, ' ')
153
154  return (flat.length > width ? `${flat.slice(0, width - 1)}…` : flat).padEnd(width)
155}
156
157/** The first rows as an aligned plain-text table, header first, cut to LIMIT characters. */
158export function previewOf(header: string[], data: string[][]): { text: string; isCut: boolean } {
159  const cols = Math.min(PREVIEW_COLUMNS, data.slice(0, PREVIEW_ROWS).reduce((most, r) => Math.max(most, r.length), header.length))
160  const line = (r: string[]) => Array.from({ length: cols }, (_, j) => fit(r[j] ?? '', CELL)).join(' │ ').trimEnd()
161  const rule = Array.from({ length: cols }, () => '─'.repeat(CELL)).join('─┼─')
162  const text = [line(header), rule, ...data.slice(0, PREVIEW_ROWS).map(line)].join('\n')
163  if (text.length <= LIMIT) return { text, isCut: false }
164
165  return { text: text.slice(0, text.lastIndexOf('\n', LIMIT)), isCut: true }
166}
167
168/** Everything the pane shows for one file's text. */
169export function analyse(path: string, text: string, sizeBytes: number): CsvTableReport {
170  const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
171  const delimiter = detectDelimiter(body, extOf(path))
172  const { rows, isOpen } = parse(body, delimiter)
173  const header = rows[0] ?? []
174  const data = rows.slice(1)
175  const columns = columnsOf(header, data)
176  const preview = previewOf(header, data)
177  const ragged = data.filter(r => r.length !== header.length).length
178  const warnings: string[] = []
179  if (isOpen) warnings.push('The file ends inside a quoted field: a quote is probably missing.')
180  if (ragged > 0) warnings.push(`${ragged} row${ragged === 1 ? ' has' : 's have'} a different number of fields than the header.`)
181
182  return {
183    path,
184    name: nameOf(path),
185    sizeBytes,
186    delimiter: DELIMITER_NAMES[delimiter],
187    rowCount: data.length,
188    columns: columns.slice(0, MAX_LISTED),
189    columnCount: columns.length,
190    preview: preview.text,
191    previewRows: Math.min(PREVIEW_ROWS, data.length),
192    isCut: preview.isCut || columns.length > PREVIEW_COLUMNS,
193    warnings,
194  }
195}
196
197export const thousands = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
198
199export function summaryOf(r: CsvTableReport): string {
200  return `table-preview · ${r.name}: ${thousands(r.rowCount)} rows × ${r.columnCount} columns (${r.delimiter}-separated)`
201}
202
203/** The whole report as text, for a session where nothing draws panes. */
204export function reportText(r: CsvTableReport): string {
205  const cols = r.columns.map((c, i) => `  ${i + 1}. ${c.name} · ${c.type}${c.emptyPct > 0 ? ` · ${c.emptyPct}% empty` : ''}`)
206
207  return [summaryOf(r), ...r.warnings, 'Columns:', ...cols, '', r.preview].join('\n')
208}
209
types/index.d.ts 35 lines
1/** One column: its header, a simple type, and the share of empty (or NA) cells. */
2export type CsvTableColumn = { name: string; type: 'number' | 'text' | 'empty'; emptyPct: number }
3
4/** What `/table-preview <path>` read from one file. */
5export type CsvTableReport = {
6  /** The path as typed. */
7  path: string
8  /** The file name alone. */
9  name: string
10  sizeBytes: number
11  /** 'comma', 'semicolon' or 'tab'. */
12  delimiter: string
13  /** Data rows, the header not counted. The whole file is read, so this is exact. */
14  rowCount: number
15  /** The columns with their types, at most 60 listed. */
16  columns: CsvTableColumn[]
17  columnCount: number
18  /** The first rows as an aligned plain-text table, bounded. */
19  preview: string
20  previewRows: number
21  /** True when the preview leaves columns out or was cut to fit. */
22  isCut: boolean
23  /** Problems seen while parsing (an unclosed quote, ragged rows). */
24  warnings: string[]
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'table-preview': {
30      /** The last file read, or null before the first `/table-preview`. */
31      report: CsvTableReport | null
32    }
33  }
34}
35