xtrm-tools 0.7.19 → 0.7.20

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.
package/cli/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
3
  "private": true,
4
- "version": "0.7.19",
4
+ "version": "0.7.20",
5
5
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
6
6
  "main": "./dist/index.js",
7
7
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.7.19",
3
+ "version": "0.7.20",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,3 +41,10 @@ Pi discovers this package through:
41
41
  - `pi.extensions: ["./src/index.ts"]`
42
42
 
43
43
  After install, keep `.pi/settings.json` package wiring pointed at `npm:@jaggerxtrm/pi-extensions`.
44
+
45
+ ## Managed extensions
46
+
47
+ Notable bundled extensions include:
48
+
49
+ - `xtrm-ui` — XTRM Pi chrome, native tool summaries, selectable external tool chrome (`/xtrm-ui chrome background|box`).
50
+ - `sp-terminal-overlay` — `/sp-feed`, `/sp-ps`, and `/xtrm-terminal` streaming overlays for specialist monitoring.
@@ -3,3 +3,15 @@
3
3
  This directory is the canonical source for managed Pi extension entrypoints.
4
4
 
5
5
  Runtime delivery is package-based via `npm:@jaggerxtrm/pi-extensions`.
6
+
7
+ ## sp-terminal-overlay
8
+
9
+ Streaming terminal-style overlay for specialist/process monitoring commands.
10
+
11
+ Commands:
12
+
13
+ - `/sp-feed [args]` — opens `sp feed -f [args]` in an overlay.
14
+ - `/sp-ps [args]` / `/xtrm-ps [args]` — opens `sp ps [args]` (defaults to `sp ps --follow`) in an overlay.
15
+ - `/xtrm-terminal <command>` — opens an arbitrary shell command in an overlay.
16
+
17
+ Keys: `Esc`/`q` close, `r` restart, arrows/page keys scroll.
@@ -0,0 +1,495 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
3
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
+
5
+ const MAX_BUFFER_LINES = 2000;
6
+ const DEFAULT_VISIBLE_LINES = 24;
7
+ const RENDER_THROTTLE_MS = 100;
8
+ const ANSI_SGR_PATTERN = /^\x1b\[[0-9;]*m$/u;
9
+ const DISALLOWED_SGR_CODES = new Set([5, 6, 8]);
10
+
11
+ function padVisible(text: string, width: number): string {
12
+ return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
13
+ }
14
+
15
+ function resetAnsi(text: string): string {
16
+ return text.includes("\x1b") ? `${text}\x1b[0m` : text;
17
+ }
18
+
19
+ function shellQuote(value: string): string {
20
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
21
+ }
22
+
23
+ function resolveSpFeedCommand(args: string): string {
24
+ const trimmed = args.trim();
25
+ return trimmed ? `sp feed -f ${trimmed}` : "sp feed -f";
26
+ }
27
+
28
+ function resolveSpPsCommand(args: string): string {
29
+ const trimmed = args.trim();
30
+ return trimmed ? `sp ps ${trimmed}` : "sp ps --follow";
31
+ }
32
+
33
+ function openTerminalOverlay(ctx: ExtensionCommandContext, title: string, command: string): Promise<void> {
34
+ return ctx.ui.custom<void>(
35
+ (tui, theme, _keybindings, done) => {
36
+ const terminal = new StreamingTerminalOverlay({
37
+ title,
38
+ command,
39
+ cwd: process.cwd(),
40
+ theme,
41
+ requestRender: () => {
42
+ tui.requestRender();
43
+ },
44
+ close: () => {
45
+ terminal.dispose();
46
+ done(undefined);
47
+ },
48
+ });
49
+ return terminal;
50
+ },
51
+ {
52
+ overlay: true,
53
+ overlayOptions: {
54
+ anchor: "center",
55
+ width: "80%",
56
+ minWidth: 72,
57
+ maxHeight: "80%",
58
+ margin: 1,
59
+ },
60
+ } as Parameters<ExtensionCommandContext["ui"]["custom"]>[1],
61
+ ).then(() => undefined);
62
+ }
63
+
64
+ type StreamingTerminalOverlayOptions = {
65
+ title: string;
66
+ command: string;
67
+ cwd: string;
68
+ theme: Theme;
69
+ requestRender: () => void;
70
+ close: () => void;
71
+ };
72
+
73
+ class StreamingTerminalOverlay {
74
+ private child: ChildProcessWithoutNullStreams | undefined;
75
+ private lines: string[] = [];
76
+ private currentLine = "";
77
+ private screenLines: string[] = [];
78
+ private cursorRow = 0;
79
+ private cursorCol = 0;
80
+ private terminalMode = false;
81
+ private scrollOffset = 0;
82
+ private status = "starting";
83
+ private closed = false;
84
+ private renderTimer: ReturnType<typeof setTimeout> | undefined;
85
+ private lastRenderAt = 0;
86
+
87
+ constructor(private readonly options: StreamingTerminalOverlayOptions) {
88
+ this.start();
89
+ }
90
+
91
+ handleInput(data: string): void {
92
+ if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "ctrl+c")) {
93
+ this.options.close();
94
+ return;
95
+ }
96
+ if (matchesKey(data, "r")) {
97
+ this.restart();
98
+ return;
99
+ }
100
+ if (matchesKey(data, "up")) {
101
+ this.scrollOffset = Math.min(this.scrollOffset + 1, Math.max(0, this.renderSourceLines().length - 1));
102
+ this.options.requestRender();
103
+ return;
104
+ }
105
+ if (matchesKey(data, "down")) {
106
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
107
+ this.options.requestRender();
108
+ return;
109
+ }
110
+ if (matchesKey(data, "pageup")) {
111
+ this.scrollOffset = Math.min(this.scrollOffset + DEFAULT_VISIBLE_LINES, Math.max(0, this.renderSourceLines().length - 1));
112
+ this.options.requestRender();
113
+ return;
114
+ }
115
+ if (matchesKey(data, "pagedown")) {
116
+ this.scrollOffset = Math.max(0, this.scrollOffset - DEFAULT_VISIBLE_LINES);
117
+ this.options.requestRender();
118
+ return;
119
+ }
120
+ if (matchesKey(data, "home")) {
121
+ this.scrollOffset = Math.max(0, this.renderSourceLines().length - 1);
122
+ this.options.requestRender();
123
+ return;
124
+ }
125
+ if (matchesKey(data, "end")) {
126
+ this.scrollOffset = 0;
127
+ this.options.requestRender();
128
+ }
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ const theme = this.options.theme;
133
+ const overlayWidth = Math.max(40, width);
134
+ const innerWidth = overlayWidth - 2;
135
+ const contentWidth = Math.max(1, innerWidth - 2);
136
+ const allLines = this.renderSourceLines();
137
+ const visibleCount = DEFAULT_VISIBLE_LINES;
138
+ const end = Math.max(0, allLines.length - this.scrollOffset);
139
+ const start = Math.max(0, end - visibleCount);
140
+ const visible = allLines.slice(start, end);
141
+ const hiddenAbove = start;
142
+ const hiddenBelow = Math.max(0, allLines.length - end);
143
+
144
+ const border = (text: string) => theme.fg("border", text);
145
+ const title = ` ${theme.fg("accent", theme.bold(this.options.title))} ${theme.fg("dim", this.status)} `;
146
+ const top = border("╭") + truncateToWidth(title, Math.max(0, innerWidth), "") + border("─".repeat(Math.max(0, innerWidth - visibleWidth(title)))) + border("╮");
147
+ const row = (content: string) => {
148
+ const truncated = resetAnsi(truncateToWidth(content, contentWidth));
149
+ return `${border("│")} ${padVisible(truncated, contentWidth)} ${border("│")}`;
150
+ };
151
+
152
+ const output = [top];
153
+ output.push(row(theme.fg("dim", `$ ${this.options.command}`)));
154
+ output.push(row(theme.fg("dim", "Esc/q close • r restart • ↑↓ scroll • PgUp/PgDn page")));
155
+ output.push(row(""));
156
+ const bodyRows: string[] = [];
157
+ if (hiddenAbove > 0) bodyRows.push(theme.fg("dim", `… ${hiddenAbove} lines above`));
158
+ bodyRows.push(...visible);
159
+ if (visible.length === 0) bodyRows.push(theme.fg("dim", "waiting for output…"));
160
+ if (hiddenBelow > 0) bodyRows.push(theme.fg("dim", `… ${hiddenBelow} lines below`));
161
+
162
+ for (let index = 0; index < visibleCount; index++) {
163
+ output.push(row(bodyRows[index] ?? ""));
164
+ }
165
+ output.push(border("╰" + "─".repeat(innerWidth) + "╯"));
166
+ return output;
167
+ }
168
+
169
+ invalidate(): void {}
170
+
171
+ dispose(): void {
172
+ this.closed = true;
173
+ if (this.renderTimer) clearTimeout(this.renderTimer);
174
+ this.renderTimer = undefined;
175
+ this.stop();
176
+ }
177
+
178
+ private start(): void {
179
+ this.stop();
180
+ this.status = "running";
181
+ this.lines = [];
182
+ this.currentLine = "";
183
+ this.screenLines = [];
184
+ this.cursorRow = 0;
185
+ this.cursorCol = 0;
186
+ this.terminalMode = false;
187
+ this.scrollOffset = 0;
188
+
189
+ const shell = process.env.SHELL || "/bin/sh";
190
+ this.child = spawn(shell, ["-lc", this.options.command], {
191
+ cwd: this.options.cwd,
192
+ env: {
193
+ ...process.env,
194
+ FORCE_COLOR: process.env.FORCE_COLOR ?? "1",
195
+ TERM: process.env.TERM ?? "xterm-256color",
196
+ COLUMNS: process.env.COLUMNS ?? "120",
197
+ LINES: process.env.LINES ?? "40",
198
+ },
199
+ stdio: ["pipe", "pipe", "pipe"],
200
+ });
201
+
202
+ this.child.stdout.on("data", (chunk) => this.append(String(chunk)));
203
+ this.child.stderr.on("data", (chunk) => this.append(String(chunk)));
204
+ this.child.on("error", (error) => {
205
+ this.status = "error";
206
+ this.append(`\n[error] ${error.message}\n`);
207
+ });
208
+ this.child.on("close", (code, signal) => {
209
+ this.flushCurrentLine();
210
+ this.status = signal ? `stopped (${signal})` : `exited ${code ?? "unknown"}`;
211
+ this.requestRenderSoon(true);
212
+ });
213
+
214
+ this.requestRenderSoon(true);
215
+ }
216
+
217
+ private restart(): void {
218
+ this.start();
219
+ }
220
+
221
+ private stop(): void {
222
+ const child = this.child;
223
+ this.child = undefined;
224
+ if (child && !child.killed) {
225
+ child.kill("SIGTERM");
226
+ setTimeout(() => {
227
+ if (!child.killed) child.kill("SIGKILL");
228
+ }, 750).unref?.();
229
+ }
230
+ }
231
+
232
+ private requestRenderSoon(immediate = false): void {
233
+ if (this.closed) return;
234
+ if (immediate) {
235
+ if (this.renderTimer) clearTimeout(this.renderTimer);
236
+ this.renderTimer = undefined;
237
+ this.lastRenderAt = Date.now();
238
+ this.options.requestRender();
239
+ return;
240
+ }
241
+
242
+ const now = Date.now();
243
+ const elapsed = now - this.lastRenderAt;
244
+ if (elapsed >= RENDER_THROTTLE_MS) {
245
+ this.lastRenderAt = now;
246
+ this.options.requestRender();
247
+ return;
248
+ }
249
+
250
+ if (this.renderTimer) return;
251
+ this.renderTimer = setTimeout(() => {
252
+ this.renderTimer = undefined;
253
+ this.lastRenderAt = Date.now();
254
+ this.options.requestRender();
255
+ }, RENDER_THROTTLE_MS - elapsed);
256
+ this.renderTimer.unref?.();
257
+ }
258
+
259
+ private renderSourceLines(): string[] {
260
+ if (this.terminalMode) {
261
+ return this.screenLines.map((line) => line.replace(/\s+$/u, ""));
262
+ }
263
+ return this.currentLine ? [...this.lines, this.currentLine] : this.lines;
264
+ }
265
+
266
+ private append(text: string): void {
267
+ if (this.closed) return;
268
+ for (let index = 0; index < text.length; index++) {
269
+ const char = text[index]!;
270
+ if (char === "\x1b") {
271
+ const sgrMatch = text.slice(index).match(/^\x1b\[[0-9;]*m/u);
272
+ if (sgrMatch?.[0]) {
273
+ this.appendSafeSgr(sgrMatch[0]);
274
+ index += sgrMatch[0].length - 1;
275
+ continue;
276
+ }
277
+ const nextIndex = this.consumeEscape(text, index);
278
+ if (nextIndex !== index) {
279
+ index = nextIndex;
280
+ continue;
281
+ }
282
+ }
283
+ this.appendChar(char);
284
+ }
285
+ this.trimBuffer();
286
+ this.requestRenderSoon();
287
+ }
288
+
289
+ private appendSafeSgr(sequence: string): void {
290
+ if (!ANSI_SGR_PATTERN.test(sequence)) return;
291
+ const params = sequence.slice(2, -1).split(";").filter(Boolean).map((part) => Number.parseInt(part, 10));
292
+ if (params.some((param) => Number.isNaN(param) || DISALLOWED_SGR_CODES.has(param))) return;
293
+
294
+ // Cursor-addressed dashboards need cell-aware mutation. Keeping raw SGR inside
295
+ // screenLines makes cursorCol slicing unsafe, so preserve colors only for
296
+ // append-only feed output.
297
+ if (this.terminalMode) return;
298
+ this.currentLine += sequence;
299
+ }
300
+
301
+ private appendChar(char: string): void {
302
+ if (char === "\r") {
303
+ if (this.terminalMode) this.cursorCol = 0;
304
+ else this.currentLine = "";
305
+ return;
306
+ }
307
+ if (char === "\n") {
308
+ if (this.terminalMode) {
309
+ this.cursorRow++;
310
+ this.cursorCol = 0;
311
+ this.ensureScreenLine(this.cursorRow);
312
+ } else {
313
+ this.flushCurrentLine();
314
+ }
315
+ return;
316
+ }
317
+ if (char === "\b" || char === "\x7f") {
318
+ if (this.terminalMode) this.cursorCol = Math.max(0, this.cursorCol - 1);
319
+ else this.currentLine = this.currentLine.slice(0, -1);
320
+ return;
321
+ }
322
+ if (char < " " && char !== "\t") return;
323
+
324
+ if (this.terminalMode) {
325
+ this.writeScreenChar(char === "\t" ? " " : char);
326
+ return;
327
+ }
328
+ this.currentLine += char;
329
+ }
330
+
331
+ private consumeEscape(text: string, start: number): number {
332
+ const introducer = text[start + 1];
333
+ if (introducer === "[") {
334
+ let end = start + 2;
335
+ while (end < text.length && !/[A-Za-z~]/.test(text[end]!)) end++;
336
+ if (end >= text.length) return start;
337
+ this.handleCsi(text.slice(start + 2, end), text[end]!);
338
+ return end;
339
+ }
340
+ if (introducer === "]") {
341
+ let end = start + 2;
342
+ while (end < text.length) {
343
+ if (text[end] === "\x07") return end;
344
+ if (text[end] === "\x1b" && text[end + 1] === "\\") return end + 1;
345
+ end++;
346
+ }
347
+ return start;
348
+ }
349
+ if (introducer) return start + 1;
350
+ return start;
351
+ }
352
+
353
+ private handleCsi(params: string, final: string): void {
354
+ const numbers = params
355
+ .replace(/[?>!]/g, "")
356
+ .split(";")
357
+ .map((part) => Number.parseInt(part || "0", 10));
358
+ const first = numbers[0] ?? 0;
359
+
360
+ if (final === "m") return;
361
+ if (final === "H" || final === "f") {
362
+ this.enterTerminalMode();
363
+ this.cursorRow = Math.max(0, (numbers[0] || 1) - 1);
364
+ this.cursorCol = Math.max(0, (numbers[1] || 1) - 1);
365
+ this.ensureScreenLine(this.cursorRow);
366
+ return;
367
+ }
368
+ if (final === "J") {
369
+ this.enterTerminalMode();
370
+ if (first === 2 || first === 3) {
371
+ this.screenLines = [];
372
+ this.cursorRow = 0;
373
+ this.cursorCol = 0;
374
+ this.ensureScreenLine(0);
375
+ } else if (first === 0) {
376
+ this.screenLines = this.screenLines.slice(0, this.cursorRow + 1);
377
+ this.clearScreenLineFromCursor();
378
+ }
379
+ return;
380
+ }
381
+ if (final === "K") {
382
+ this.enterTerminalMode();
383
+ if (first === 2) this.screenLines[this.cursorRow] = "";
384
+ else this.clearScreenLineFromCursor();
385
+ return;
386
+ }
387
+ if (final === "A") {
388
+ this.enterTerminalMode();
389
+ this.cursorRow = Math.max(0, this.cursorRow - Math.max(1, first));
390
+ return;
391
+ }
392
+ if (final === "B") {
393
+ this.enterTerminalMode();
394
+ this.cursorRow += Math.max(1, first);
395
+ this.ensureScreenLine(this.cursorRow);
396
+ return;
397
+ }
398
+ if (final === "C") {
399
+ this.enterTerminalMode();
400
+ this.cursorCol += Math.max(1, first);
401
+ return;
402
+ }
403
+ if (final === "D") {
404
+ this.enterTerminalMode();
405
+ this.cursorCol = Math.max(0, this.cursorCol - Math.max(1, first));
406
+ }
407
+ }
408
+
409
+ private enterTerminalMode(): void {
410
+ if (this.terminalMode) return;
411
+ this.terminalMode = true;
412
+ this.screenLines = this.currentLine ? [...this.lines, this.currentLine] : [...this.lines];
413
+ if (this.screenLines.length === 0) this.screenLines.push("");
414
+ this.cursorRow = Math.max(0, this.screenLines.length - 1);
415
+ this.cursorCol = visibleWidth(this.screenLines[this.cursorRow] ?? "");
416
+ this.currentLine = "";
417
+ }
418
+
419
+ private ensureScreenLine(row: number): void {
420
+ while (this.screenLines.length <= row) this.screenLines.push("");
421
+ }
422
+
423
+ private writeScreenChar(char: string): void {
424
+ this.ensureScreenLine(this.cursorRow);
425
+ const line = this.screenLines[this.cursorRow] ?? "";
426
+ const padded = line.length < this.cursorCol ? line + " ".repeat(this.cursorCol - line.length) : line;
427
+ this.screenLines[this.cursorRow] = padded.slice(0, this.cursorCol) + char + padded.slice(this.cursorCol + 1);
428
+ this.cursorCol++;
429
+ }
430
+
431
+ private clearScreenLineFromCursor(): void {
432
+ this.ensureScreenLine(this.cursorRow);
433
+ this.screenLines[this.cursorRow] = (this.screenLines[this.cursorRow] ?? "").slice(0, this.cursorCol);
434
+ }
435
+
436
+ private flushCurrentLine(): void {
437
+ if (this.terminalMode) return;
438
+ this.lines.push(this.currentLine);
439
+ this.currentLine = "";
440
+ this.trimBuffer();
441
+ }
442
+
443
+ private trimBuffer(): void {
444
+ if (this.lines.length > MAX_BUFFER_LINES) this.lines.splice(0, this.lines.length - MAX_BUFFER_LINES);
445
+ if (this.screenLines.length > MAX_BUFFER_LINES) this.screenLines.splice(0, this.screenLines.length - MAX_BUFFER_LINES);
446
+ }
447
+ }
448
+
449
+ export default function spTerminalOverlayExtension(pi: ExtensionAPI): void {
450
+ pi.registerCommand("sp-feed", {
451
+ description: "Open a streaming overlay for `sp feed -f`",
452
+ handler: async (args, ctx) => {
453
+ await openTerminalOverlay(ctx, "sp feed", resolveSpFeedCommand(args));
454
+ },
455
+ });
456
+
457
+ pi.registerCommand("sp-ps", {
458
+ description: "Open a streaming overlay for `sp ps --follow`",
459
+ handler: async (args, ctx) => {
460
+ await openTerminalOverlay(ctx, "sp ps", resolveSpPsCommand(args));
461
+ },
462
+ });
463
+
464
+ pi.registerCommand("xtrm-ps", {
465
+ description: "Alias for /sp-ps",
466
+ handler: async (args, ctx) => {
467
+ await openTerminalOverlay(ctx, "sp ps", resolveSpPsCommand(args));
468
+ },
469
+ });
470
+
471
+ pi.registerCommand("xtrm-terminal", {
472
+ description: "Open a streaming terminal overlay for an arbitrary shell command",
473
+ handler: async (args, ctx) => {
474
+ const command = args.trim();
475
+ if (!command) {
476
+ ctx.ui.notify("Usage: /xtrm-terminal <command>", "warning");
477
+ return;
478
+ }
479
+ await openTerminalOverlay(ctx, command, command);
480
+ },
481
+ });
482
+
483
+ pi.registerCommand("xtrm-terminal-file", {
484
+ description: "Open a streaming overlay for a command with one shell-quoted file/path argument",
485
+ handler: async (args, ctx) => {
486
+ const [commandName, ...rest] = args.trim().split(/\s+/u);
487
+ const fileArg = rest.join(" ");
488
+ if (!commandName || !fileArg) {
489
+ ctx.ui.notify("Usage: /xtrm-terminal-file <command> <path>", "warning");
490
+ return;
491
+ }
492
+ await openTerminalOverlay(ctx, commandName, `${commandName} ${shellQuote(fileArg)}`);
493
+ },
494
+ });
495
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@xtrm/pi-sp-terminal-overlay",
3
+ "version": "0.1.0",
4
+ "description": "XTRM Pi overlay for streaming sp feed and terminal command output",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "license": "MIT",
10
+ "pi": {
11
+ "extensions": [
12
+ "./index.ts"
13
+ ]
14
+ }
15
+ }
@@ -258,6 +258,9 @@ export function formatLineLabel(count: number, noun: string): string {
258
258
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
259
259
  }
260
260
 
261
+ const TOOL_SUMMARY_SUBJECT_MAX = 34;
262
+ const TOOL_SUMMARY_META_MAX = 34;
263
+
261
264
  export function renderToolSummary(
262
265
  theme: { fg(color: string, text: string): string; bold(text: string): string },
263
266
  status: "pending" | "success" | "error" | "muted",
@@ -270,9 +273,11 @@ export function renderToolSummary(
270
273
  : status === "error" ? "error"
271
274
  : status === "success" ? "success"
272
275
  : "muted";
276
+ const compactSubject = subject ? shortenCommand(subject, TOOL_SUMMARY_SUBJECT_MAX) : undefined;
277
+ const compactMeta = meta ? shortenCommand(meta, TOOL_SUMMARY_META_MAX) : undefined;
273
278
  let text = `${theme.fg(color, "•")} ${theme.fg("toolTitle", theme.bold(label))}`;
274
- if (subject) text += ` ${theme.fg("accent", subject)}`;
275
- if (meta) text += theme.fg("muted", ` · ${meta}`);
279
+ if (compactSubject) text += ` ${theme.fg("accent", compactSubject)}`;
280
+ if (compactMeta) text += theme.fg("muted", ` · ${compactMeta}`);
276
281
  return text;
277
282
  }
278
283