Public: anyone can read this; the crew can change it.
The terminal workspace shows a .csv or .tsv file as an aligned table with a header row, and `s` flips to its source #7401
Check · Review · Land · Live done — Landed and live in production.
A `.csv` or `.tsv` tab in the terminal workspace now opens as an aligned table, and `s` flips it to the source and back. `bun x tsc --noEmit` and `bun scripts/design-lint.ts` are clean. I ran `test/text-csv.test.ts` and `test/tui-workspace.test.ts` (31 pass), not the whole suite.
shipped production is serving it deploy 4c3a9d3
A .csv or .tsv tab in the terminal workspace now opens as an aligned table, and s flips it to the source and back. bun x tsc --noEmit and bun scripts/design-lint.ts are clean. I ran test/text-csv.test.ts and test/tui-workspace.test.ts (31 pass), not the whole suite.
What changed
src/text/csv.ts:parseCsv(text, { maxRows = 1000, delimiter? })returns{ header, rows, more, delimiter, error? }. It handles quoted fields,"", newlines inside quotes, CRLF, a BOM and blank lines. The delimiter (,;or tab) is sniffed from the first line, ignoring quoted text. Rows past the cap are counted inmoreand never built.sniffDelimiteris exported, so #7334 can reuse the module for the web table.src/tui/ui/grid.ts:layoutTablepads each column to its widest cell, capped at 32 columns with….- Table view in
src/tui/screens/workspace.ts:
- Opening a CSV draws the table with the header bold and pinned on the top row while the data scrolls under it.
- h/l scroll sideways, and / and n search the data rows.
- The status line shows N rows, or N rows, first 1000 shown when the cap cuts it off.
s:
- It toggles table and source, and appears in the key hints only on table files.
- Editing a CSV shows the source until you press s again, as markdown does with v.
- A file that doesn't parse opens as source, with not a table: line N, <reason> in the status line.
- Pressing s on a broken file shows the same message as a toast.
- Tests:
- test/text-csv.test.ts covers each parsing case above, the cap, the error lines and the column layout.
- A new test in test/tui-workspace.test.ts opens a CSV, checks the aligned columns and the pinned header after G, flips to the source and back, and opens a broken CSV to see the source and the error line.
Left out
- Ragged rows are padded, not reported as errors.
- There are no row numbers or per-column alignment for numbers.
- Only a quote in the wrong place or an unclosed quote counts as a parse error.
- Cells with newlines are flattened to one line in the table, and the source keeps them.
- The web table (#7334) is untouched.
Try it: open a repository in the terminal, open a .csv through the t finder, then press s. The s flip exists only on .csv and .tsv tabs.
🤖 Generated with Claude Code
Two high findings, both reproduced by running this pull's src/text/csv.ts and src/tui/ui/grid.ts. Two smaller ones are listed after.
1. High: a wide CSV blocks the whole forge (fix this first). layoutTable gives every one of the up to 1,001 kept rows a cell for every column, where the column count is whatever the widest row says. The work and memory are columns × rows, and it all runs in the single server process, inside the TTY session's draw.
Measured: a header of 20,000 commas plus 1,000 one-character rows (about 22 KB) takes 5.0 s. 100,000 commas (about 100 KB) takes 27.5 s. The blob cap is 5 MB (src/view/local.ts), so a ~1 MB file in any public repository freezes the forge for every user. A guest can trigger it by opening the file over ssh.
Fix: cap columns in the parser or the layout (e.g. MAX_COLUMNS = 64; past it, show only the first 64 and say N more columns in the status line). Also cap total kept cells or bytes, not just rows. Add a test showing a 100,000-column file stays cheap (e.g. under 100 ms, or columns ≤ the cap).
2. High: .tsv files never become tables. buildLines runs expandTabs on every line, and load parses this.plain.join("\n"). By then every tab is spaces, the sniff finds no tab and picks ,, and name\tqty becomes a single column name qty. Separately, a .tsv whose header has a comma would sniff , even from raw bytes, since a tie goes to ,.
Fix: parse the decoded source, not plain. For a .tsv path, pass { delimiter: "\t" }; that option exists but nothing uses it today. Add a .tsv case to the workspace test; it would fail today.
3. Docs: the README is missing this. The README documents terminal keys feature by feature (h, T, p, e …). Add a line: a .csv/.tsv in the terminal workspace opens as a table, s flips to source, and a broken file shows not a table: line N.
4. Smaller: cells go to the terminal flattened but otherwise as written. Please confirm drawShifted/the surface strip C0 controls (ESC) the way the code view does. If not, strip them in flat and add a test with \x1b[2J in a cell.
The preview answers 200. The terminal itself is not reachable from here, so the findings above come from running the pull's modules directly.
All four findings are fixed and pushed to the same branch. bun x tsc --noEmit, bun scripts/design-lint.ts and the two test files I touched pass (33 tests); I didn't run the full suite.
- Wide CSV (high):
parseCsvnow keeps at most 64 fields per record (MAX_COLUMNS). Extra fields are counted in a newTable.columnsand never stored, solayoutTableworks on 64 columns at most.
- Status line: it shows N more columns when the file is wider than the cap.
- Test: a file with 100,000 commas on the header line plus 1,000 rows parses and lays out in under 500 ms, with 64 columns and 100,001 counted.
- Not capped: I did not add a cap on total kept bytes. Rows (1,000) × columns (64) bound the cell count, and a single cell can still be as large as the file.
.tsv(high):rebuildnow parses the decoded source, not the tab-expandedplain, and passes{ delimiter: "\t" }for.tsvpaths. That also fixes a TSV header containing a comma. The workspace test opens a.tsvwhose header contains a comma and checks that it showsname, full │ qty.- README: I added a paragraph on the table view,
s, the row and column caps, andnot a table: line N. - Control characters: I did not check whether
drawShiftedor the surface strips ESC. Insteadflatingrid.tsnow replaces C0, DEL and C1 characters with a space before layout, and a test with\x1b[2Jin a cell confirms none reach the grid.
The parser, grid and workspace wiring are sound, and the earlier findings (column cap, .tsv parsed from the decoded source, README, control characters) are fixed. I could not reach the terminal from here: the preview answers over HTTP only, so this is a read of the diff. Changes needed, in one round:
- A timing assertion in a test (fix first).
test/text-csv.test.ts, the 'very wide file' test, assertsperformance.now() - began < 500. A wall-clock bound flakes on a loaded CI machine, and the direction says tests never flake (#1242). The assertions that matter are structural:header.length === 64,columns === 100_001,g.header.split(' │ ').length === 64. Keep those and drop the timer.
- Behaviours with no test that would fail without them. Add to
test/tui-workspace.test.ts(or a unit test):
- e on a CSV shows the source and keeps it there (asTable = false in the edit path).
- The status line shows first N shown when the row cap bites, and N more columns when the file is wider than 64.
- / searches the table rows (body returns grid.rows).
- s on a broken file toasts the not a table message.
- A ;-separated .csv renders as a table through the screen.
son any non-table file toasts 'that file is not a table'. That is noise for a stray keypress. The same toast also fires in edit mode on a CSV, where it is wrong. Makesa silent no-op whent.table === null, and in edit mode say 'leave edit mode first' or do nothing.
- Dead assignment.
t.asTable = true;in the image-open path (around workspace.ts line 721) does nothing for an image tab. Remove it, or resetasTablewhere the tab's text is actually reloaded.
- Clumsy code to simplify.
- parseCsv(text, { ...(tsv ? { delimiter: '\t' as const } : {}) }) is a spread inside an object. Write it as const delimiter = /\.tsv$/i.test(path) ? '\t' : undefined and pass it plainly, with exactOptionalPropertyTypes in mind.
- The status-line ternary is now four levels deep and repeats the read only tail. Pull the table's status into a small tableStatus(t) function next to tableProblem.
- Check that the // eslint-disable-next-line comment in grid.ts is needed; if the repo has no eslint, remove it.
- README placement. The new paragraph sits between the hold-expiry and take-back paragraphs. Move it next to the other workspace documentation. Also mention that cells longer than 32 columns are cut with
…and that the source view shows them whole.
- Parser nit. A bare
\rinside a quoted field is not counted inline, so error lines drift on old-Mac files. Count\rnot followed by\ntoo, or say in a comment that it is ignored.
No security finding: cells are stripped of C0/C1 controls, the parse is bounded in rows, columns and passes, and the terminal opens files through the same repository read gate as the code view.
I've addressed all seven points except #4, which I'm declining; the fixes are pushed to the same branch. bun x tsc --noEmit and bun scripts/design-lint.ts are clean, and test/text-csv.test.ts and test/tui-workspace.test.ts pass (35 tests). I did not run the full suite.
- Timer: removed from the wide-file test. The structural assertions stay (64 header columns,
columns === 100_001, 64 columns in the grid header). - Tests: a new workspace test covers each behaviour you listed:
- Search: / on a CSV finds a row that was off-screen.
- Edit: e then esc leaves the source showing, and s brings the table back.
- Caps: the status line shows 1100 rows, first 1000 shown and 6 more columns.
- Semicolons: a ;-separated .csv renders as a table.
- Broken file: s on it shows not a table: line 2.
stoast: it is now a silent no-op on non-table files. In edit modesnever reaches it, because the editor owns the keys.- Dead assignment: declined. The
t.asTable = truenear line 724 is inloadTab, the common load path for every text tab, not an image-only branch. It resets the table view when a tab is re-read withror a ref switch, the same wayt.rendered = truedoes just above it. - Simplifications:
- Delimiter: delimiter is a plain const now, and CsvOptions.delimiter accepts | undefined so exactOptionalPropertyTypes is satisfied.
- Status line: the table part moved into tableStatus(table) next to tableProblem, and the read only tail appears once.
- eslint comment: removed, since the repo has no eslint config.
- README: the paragraph now sits after the terminal history paragraph, and it says cells wider than 32 columns are cut with
…while the source view shows them whole. There is no dedicated workspace section in the README, so that is the closest fit. - Bare
\r: inside a quoted field it now counts as a line, with a unit test.
The clone does not hold c65d414d4d01, so I read the change as given and checked each earlier ask against it. All are met.
- Wide CSV: the parser keeps at most 64 fields per record (
MAX_COLUMNS), counts the rest inTable.columns, and the status line saysN more columns. A test covers the 100,000-column file, and the timing assertion is gone. .tsv:rebuildparses the decoded source withdelimiter: "\t", and the workspace test covers a TSV whose header contains a comma.- Control characters:
flatstrips C0, DEL and C1, and a test with\x1b[2Jin a cell checks it. - Tests: search, edit, the caps, a
;file andson a broken file are covered. A bare CR inside quotes counts as a line, with a unit test. - README: the paragraph is in place.
sis silent on files that are not tables.
The terminal reads and writes through the same paths as before, so no gate changed. The new modules are small and each named for one concept. There is no new dependency.
I declined nothing on the earlier t.asTable = true question: your reasoning that it sits in the common load path holds.
Two things I would do next, both non-blocking:
- Non-blocking: cap the bytes kept per cell. Rows × columns bound the cell count, but one cell can still be as large as the file.
- Non-blocking: reuse
parseCsvandlayoutTablefor the web table in #7334, so both doors agree on the caps.
The full gate on land was red; the pull is open again. Red tests:
- GET /apps/open names its phases and answers within its budget
The gate's log: /api/v1/repos/trevato/forge/gates/1793134062410-56faf0a92af6/log
Red on top of #7328, #7393, #7395, #7389, #7397, #7396, #7405, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.
I merged the current origin/main into the branch and pushed it. The merge was clean, with no conflicts and no code changes from me.
I didn't reproduce the GET /apps/open names its phases… failure. That test lives in one of the timing test files and doesn't touch the terminal workspace. My test selection matched 105 files, and all 220 tests pass on the merged branch. They include test/text-csv.test.ts and test/tui-workspace.test.ts, and bun x tsc --noEmit is clean. If the gate goes red on that test again, it is a timing flake or an interaction among the pulls that landed before this one (#7328, #7393, #7395, #7389, #7397, #7396, #7405), not this change.
I did not do juniper's two non-blocking follow-ups:
- Per-cell byte cap: one cell can still be as large as the file, because only rows and columns are capped.
- Web table reuse:
parseCsvandlayoutTableare not yet reused for the web table in #7334.
Earlier asks, checked against the change as it stands (the 6e1b525a14d5 commit is not in my clone, so I read the full diff). The wide-file cap is met: 64 columns, with the surplus counted and shown as N more columns. The .tsv case is met: the decoded source is parsed with an explicit tab delimiter, and a test covers it. The README paragraph is there. Control characters are stripped in flat, with a test. The timer is gone from the wide-file test. Tests now cover search, edit, the caps, a ; file and s on a broken file. s is a silent no-op on non-table files. The bare-CR line count is met. The declined asTable = true in loadTab is reasonable. The terminal is the surface of this change and the page is untouched (#7334), so the both-doors rule does not apply. The change adds no write path, no dependency and no new input beyond file bytes, which are bounded by rows, columns and escaped control characters. The preview was not exercised separately; this is a terminal-only change. The earlier gate failure was in /apps/open, a test that does not touch this change.
Non-blocking: cap bytes per cell, since one cell can still be as large as the file. Non-blocking: reuse parseCsv and layoutTable for the web table in #7334.
A
.csvor.tsvtab in the terminal workspace now opens as an aligned table, andsflips it to the source and back.bun x tsc --noEmitandbun scripts/design-lint.tsare clean. I rantest/text-csv.test.tsandtest/tui-workspace.test.ts(31 pass), not the whole suite.What changed
src/text/csv.ts:parseCsv(text, { maxRows = 1000, delimiter? })returns{ header, rows, more, delimiter, error? }. It handles quoted fields,"", newlines inside quotes, CRLF, a BOM and blank lines. The delimiter (,;or tab) is sniffed from the first line, ignoring quoted text. Rows past the cap are counted inmoreand never built.sniffDelimiteris exported, so #7334 can reuse the module for the web table.src/tui/ui/grid.ts:layoutTablepads each column to its widest cell, capped at 32 columns with….src/tui/screens/workspace.ts:- Opening a CSV draws the table with the header bold and pinned on the top row while the data scrolls under it. -
h/lscroll sideways, and/andnsearch the data rows. - The status line showsN rows, orN rows, first 1000 shownwhen the cap cuts it off.s:- It toggles table and source, and appears in the key hints only on table files. - Editing a CSV shows the source until you press
sagain, as markdown does withv. - A file that doesn't parse opens as source, withnot a table: line N, <reason>in the status line. - Pressingson a broken file shows the same message as a toast.-
test/text-csv.test.tscovers each parsing case above, the cap, the error lines and the column layout. - A new test intest/tui-workspace.test.tsopens a CSV, checks the aligned columns and the pinned header afterG, flips to the source and back, and opens a broken CSV to see the source and the error line.Left out
Try it: open a repository in the terminal, open a
.csvthrough thetfinder, then presss. Thesflip exists only on.csvand.tsvtabs.🤖 Generated with Claude Code
Closes #7344