opencode-responsive-tables 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/index.ts +233 -0
  4. package/package.json +40 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mrm007
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # opencode-responsive-tables
2
+
3
+ An [OpenCode](https://opencode.ai) plugin that makes markdown tables readable on any screen. Tables that fit your terminal are left alone — tables that don't are reformatted as stacked cards.
4
+
5
+ ## Before / After
6
+
7
+ A table too wide for the terminal:
8
+
9
+ ```
10
+ | Name | Age | City | Country | Email |
11
+ | ----- | --- | -------- | ------- | ----------------- |
12
+ | Alice | 30 | New York | USA | alice@example.com |
13
+ | Bob | 25 | London | UK | bob@example.com |
14
+ ```
15
+
16
+ Becomes:
17
+
18
+ ```
19
+ **Name**: Alice
20
+ **Age**: 30
21
+ **City**: New York
22
+ **Country**: USA
23
+ **Email**: alice@example.com
24
+ ────────────────────────
25
+ **Name**: Bob
26
+ **Age**: 25
27
+ **City**: London
28
+ **Country**: UK
29
+ **Email**: bob@example.com
30
+ ```
31
+
32
+ Tables that fit are passed through unchanged. Tables inside code fences are never touched.
33
+
34
+ ## Install
35
+
36
+ ```json
37
+ {
38
+ "plugin": ["opencode-responsive-tables"]
39
+ }
40
+ ```
41
+
42
+ ## How it works
43
+
44
+ 1. Detects markdown tables in assistant output
45
+ 2. Measures each table's display width (markdown-aware — bold, links, and code are measured by their rendered width, not their raw syntax)
46
+ 3. If the table fits the terminal → pass through as-is
47
+ 4. If the table overflows → reformat as stacked key-value cards with `─` separators
48
+ 5. No terminal width (e.g. OpenCode web) → all tables pass through
49
+
50
+ ## Pairs well with
51
+
52
+ Works great alongside [`@franlol/opencode-md-table-formatter`](https://github.com/franlol/opencode-md-table-formatter), which aligns and prettifies tables that fit. This plugin picks up where that one leaves off — reformatting the tables that are still too wide as stacked cards.
53
+
54
+ ## License
55
+
56
+ [MIT](LICENSE)
package/index.ts ADDED
@@ -0,0 +1,233 @@
1
+ import type { Plugin, Hooks } from "@opencode-ai/plugin";
2
+
3
+ const stringWidth = Bun.stringWidth;
4
+
5
+ // ── Plugin Entry ──────────────────────────────────────────────────
6
+
7
+ export const ResponsiveTables: Plugin = async () => {
8
+ return {
9
+ "experimental.text.complete": async (
10
+ _input: { sessionID: string; messageID: string; partID: string },
11
+ output: { text: string },
12
+ ) => {
13
+ if (typeof output.text !== "string") return;
14
+
15
+ try {
16
+ output.text = formatResponsiveTables(output.text);
17
+ } catch {}
18
+ },
19
+ } as Hooks;
20
+ };
21
+
22
+ // ── Width ─────────────────────────────────────────────────────────
23
+
24
+ function getMaxWidth(): number {
25
+ const termWidth = process.stdout.columns;
26
+ if (!termWidth) return Infinity;
27
+ return termWidth - 10;
28
+ }
29
+
30
+ // ── Table Detection (from @franlol/opencode-md-table-formatter) ───
31
+
32
+ function isTableRow(line: string): boolean {
33
+ const trimmed = line.trim();
34
+ return trimmed.startsWith("|") && trimmed.endsWith("|") && trimmed.split("|").length > 2;
35
+ }
36
+
37
+ function isSeparatorRow(line: string): boolean {
38
+ const trimmed = line.trim();
39
+ if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false;
40
+ const cells = trimmed.split("|").slice(1, -1);
41
+ return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
42
+ }
43
+
44
+ function isValidTable(lines: string[]): boolean {
45
+ if (lines.length < 2) return false;
46
+ const rows = lines.map((line) =>
47
+ line
48
+ .split("|")
49
+ .slice(1, -1)
50
+ .map((cell) => cell.trim()),
51
+ );
52
+ if (rows.length === 0 || rows[0].length === 0) return false;
53
+ const colCount = rows[0].length;
54
+ if (!rows.every((row) => row.length === colCount)) return false;
55
+ return lines.some((line) => isSeparatorRow(line));
56
+ }
57
+
58
+ function isCodeFenceLine(line: string): boolean {
59
+ return /^\s*(`{3,}|~{3,})/.test(line);
60
+ }
61
+
62
+ // ── Table Parsing ─────────────────────────────────────────────────
63
+
64
+ interface ParsedTable {
65
+ headers: string[];
66
+ dataRows: string[][];
67
+ }
68
+
69
+ function parseTable(lines: string[]): ParsedTable {
70
+ const rows = lines.map((line) =>
71
+ line
72
+ .split("|")
73
+ .slice(1, -1)
74
+ .map((cell) => cell.trim()),
75
+ );
76
+
77
+ let headers: string[] = [];
78
+ const dataRows: string[][] = [];
79
+ let headerFound = false;
80
+
81
+ for (let i = 0; i < lines.length; i++) {
82
+ if (isSeparatorRow(lines[i])) continue;
83
+ if (!headerFound) {
84
+ headers = rows[i];
85
+ headerFound = true;
86
+ } else {
87
+ dataRows.push(rows[i]);
88
+ }
89
+ }
90
+
91
+ return { headers, dataRows };
92
+ }
93
+
94
+ // ── Width Measurement (from @franlol/opencode-md-table-formatter)
95
+
96
+ const widthCache = new Map<string, number>();
97
+ let cacheOps = 0;
98
+
99
+ function getDisplayWidth(text: string): number {
100
+ const cached = widthCache.get(text);
101
+ if (cached !== undefined) return cached;
102
+ const width = measureStringWidth(text);
103
+ widthCache.set(text, width);
104
+ return width;
105
+ }
106
+
107
+ // Concealment mode: strip markdown syntax that OpenCode hides
108
+ // but preserve content inside backticks (rendered as literal text)
109
+ function measureStringWidth(text: string): number {
110
+ const codeBlocks: string[] = [];
111
+ let withPlaceholders = text.replace(/`(.+?)`/g, (_match, content) => {
112
+ codeBlocks.push(content);
113
+ return `\x00CODE${codeBlocks.length - 1}\x00`;
114
+ });
115
+
116
+ let visual = withPlaceholders;
117
+ let prev = "";
118
+ while (visual !== prev) {
119
+ prev = visual;
120
+ visual = visual
121
+ .replace(/\*\*\*(.+?)\*\*\*/g, "$1")
122
+ .replace(/\*\*(.+?)\*\*/g, "$1")
123
+ .replace(/\*(.+?)\*/g, "$1")
124
+ .replace(/~~(.+?)~~/g, "$1")
125
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1")
126
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
127
+ }
128
+
129
+ visual = visual.replace(/\x00CODE(\d+)\x00/g, (_match, index) => {
130
+ return codeBlocks[parseInt(index)];
131
+ });
132
+
133
+ return stringWidth(visual);
134
+ }
135
+
136
+ function getTableDisplayWidth(lines: string[]): number {
137
+ let max = 0;
138
+ for (const line of lines) {
139
+ max = Math.max(max, getDisplayWidth(line));
140
+ }
141
+ return max;
142
+ }
143
+
144
+ // ── Stacked Cards ─────────────────────────────────────────────────
145
+
146
+ function formatStacked(table: ParsedTable, maxWidth: number): string[] {
147
+ const { headers, dataRows } = table;
148
+ const cards: string[][] = [];
149
+ let maxLineWidth = 0;
150
+
151
+ for (const row of dataRows) {
152
+ const card: string[] = [];
153
+ for (let col = 0; col < headers.length; col++) {
154
+ const value = row[col] ?? "";
155
+ card.push(`**${headers[col]}**: ${value}`);
156
+ maxLineWidth = Math.max(
157
+ maxLineWidth,
158
+ getDisplayWidth(headers[col]) + 2 + getDisplayWidth(value),
159
+ );
160
+ }
161
+ cards.push(card);
162
+ }
163
+
164
+ const separatorWidth = Math.min(maxLineWidth, maxWidth);
165
+ const separator = "\u2500".repeat(separatorWidth);
166
+ const result: string[] = [];
167
+
168
+ for (let i = 0; i < cards.length; i++) {
169
+ result.push(...cards[i]);
170
+ if (i < cards.length - 1) {
171
+ result.push(separator);
172
+ }
173
+ }
174
+
175
+ return result;
176
+ }
177
+
178
+ // ── Orchestrator ──────────────────────────────────────────────────
179
+
180
+ export function formatResponsiveTables(text: string): string {
181
+ const maxWidth = getMaxWidth();
182
+ const lines = text.split("\n");
183
+ const result: string[] = [];
184
+ let i = 0;
185
+ let insideCodeBlock = false;
186
+
187
+ while (i < lines.length) {
188
+ if (isCodeFenceLine(lines[i])) {
189
+ insideCodeBlock = !insideCodeBlock;
190
+ result.push(lines[i]);
191
+ i++;
192
+ continue;
193
+ }
194
+
195
+ if (!insideCodeBlock && isTableRow(lines[i])) {
196
+ const tableLines: string[] = [lines[i]];
197
+ i++;
198
+ while (i < lines.length && isTableRow(lines[i])) {
199
+ tableLines.push(lines[i]);
200
+ i++;
201
+ }
202
+
203
+ if (isValidTable(tableLines)) {
204
+ const parsed = parseTable(tableLines);
205
+ const tableWidth = getTableDisplayWidth(tableLines);
206
+
207
+ if (parsed.dataRows.length === 0 || tableWidth <= maxWidth) {
208
+ result.push(...tableLines);
209
+ } else {
210
+ result.push(...formatStacked(parsed, maxWidth));
211
+ }
212
+ } else {
213
+ result.push(...tableLines);
214
+ }
215
+ } else {
216
+ result.push(lines[i]);
217
+ i++;
218
+ }
219
+ }
220
+
221
+ incrementCacheOps();
222
+ return result.join("\n");
223
+ }
224
+
225
+ // ── Cache ─────────────────────────────────────────────────────────
226
+
227
+ function incrementCacheOps() {
228
+ cacheOps++;
229
+ if (cacheOps > 100 || widthCache.size > 1000) {
230
+ widthCache.clear();
231
+ cacheOps = 0;
232
+ }
233
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "opencode-responsive-tables",
3
+ "version": "0.0.1",
4
+ "description": "Responsive Markdown table formatter for OpenCode — aligned tables when they fit, stacked cards when they don't",
5
+ "keywords": [
6
+ "formatter",
7
+ "markdown",
8
+ "opencode",
9
+ "plugin",
10
+ "responsive",
11
+ "table"
12
+ ],
13
+ "homepage": "https://github.com/mrm007/opencode-responsive-tables",
14
+ "license": "MIT",
15
+ "author": "mrm007",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+ssh://git@github.com/mrm007/opencode-responsive-tables.git"
19
+ },
20
+ "files": [
21
+ "index.ts",
22
+ "LICENSE",
23
+ "README.md"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": "./index.ts"
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "^1.3.9",
35
+ "@types/node": "^25.3.0"
36
+ },
37
+ "peerDependencies": {
38
+ "@opencode-ai/plugin": ">=0.13.7"
39
+ }
40
+ }