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.

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.
/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."") and line breaks inside quotes are part of the cell.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).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.maxKb, without reading them. It writes nothing and hooks no tool call./table-preview data/raw/survey.csv /table-preview "C:\Users\me\My data\counts.tsv"
Close the pane, or /plugin disable table-preview. Nothing runs until you call it.
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.
{ plugin: 'table-preview', key: 'report' }: the last file read (name, delimiter, rowCount, columnCount, columns, warnings, ...), or null. Nothing is kept in $.store.
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.claude plugin test), on the terminal and desktop element tables; not yet checked by hand in a live session.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.
hooks/register.tsx 98 lines1// 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}
98hooks/lib.ts 209 lines1// 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}
209types/index.d.ts 35 lines1/** 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