@agimon-ai/doompi-ui 0.0.1-alpha.37 → 0.0.1-alpha.39

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/README.md CHANGED
@@ -10,8 +10,8 @@ core. Extension authors can depend on its public contracts without adopting the
10
10
  ## Requirements
11
11
 
12
12
  - Node.js 22.19.0 or newer
13
- - Pi 0.84.2
14
- - Pi TUI 0.84.2
13
+ - Pi 0.84.3
14
+ - Pi TUI 0.84.3
15
15
 
16
16
  ## Install
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-ui",
3
- "version": "0.0.1-alpha.37",
3
+ "version": "0.0.1-alpha.39",
4
4
  "description": "Leader-key menus, themes, and TUI components for DoomPi and Pi extensions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "dist",
30
+ "web",
30
31
  "themes"
31
32
  ],
32
33
  "type": "module",
@@ -168,26 +169,37 @@
168
169
  "dependencies": {
169
170
  "@deepseek-ai/cordis": "4.0.1",
170
171
  "yaml": "2.9.0",
171
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.37",
172
- "@agimon-ai/doompi-telemetry": "0.0.1-alpha.37",
173
- "@agimon-ai/doompi-hashline": "0.0.1-alpha.9"
172
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.39",
173
+ "@agimon-ai/doompi-hashline": "0.0.1-alpha.11",
174
+ "@agimon-ai/doompi-telemetry": "0.0.1-alpha.39",
175
+ "@agimon-ai/doompi-web-components": "0.0.1-alpha.0",
176
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.2"
174
177
  },
175
178
  "devDependencies": {
176
- "@earendil-works/pi-coding-agent": "0.84.2",
177
- "@earendil-works/pi-tui": "0.84.2",
179
+ "@earendil-works/pi-coding-agent": "0.84.3",
180
+ "@earendil-works/pi-tui": "0.84.3",
181
+ "@tanstack/react-store": "0.11.1",
182
+ "@tanstack/store": "0.11.1",
178
183
  "@types/node": "26.2.0",
184
+ "@types/react": "19.2.7",
179
185
  "@vitest/coverage-v8": "4.1.11",
186
+ "react": "19.2.8",
180
187
  "tsdown": "0.22.14",
181
188
  "typescript": "7.0.2",
182
189
  "vitest": "4.1.11"
183
190
  },
184
191
  "peerDependencies": {
185
- "@earendil-works/pi-coding-agent": "0.84.2",
186
- "@earendil-works/pi-tui": "0.84.2"
192
+ "@earendil-works/pi-coding-agent": "0.84.3",
193
+ "@earendil-works/pi-tui": "0.84.3"
187
194
  },
188
195
  "engines": {
189
196
  "node": ">=22.19.0"
190
197
  },
198
+ "doompiWeb": {
199
+ "pluginId": "builtin-tools",
200
+ "channels": [],
201
+ "client": "./web/index.ts"
202
+ },
191
203
  "pi": {
192
204
  "extensions": [
193
205
  "./dist/extensions/pi.mjs"
@@ -196,7 +208,7 @@
196
208
  "scripts": {
197
209
  "build": "tsdown",
198
210
  "test": "vitest --run",
199
- "typecheck": "tsc --noEmit",
211
+ "typecheck": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json",
200
212
  "lint": "oxlint . && oxfmt . --check",
201
213
  "fixcode": "oxlint . --fix && oxfmt ."
202
214
  }
@@ -0,0 +1,32 @@
1
+ import { MessageItem, MessageItemHeader, toolTone } from '@agimon-ai/doompi-web-components';
2
+ import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
3
+ import { findCallView } from './builtinToolView.ts';
4
+ import { ListingBody, listingExpandable } from './ListingBody.tsx';
5
+
6
+ const LIMIT_KEY = 'resultLimitReached';
7
+ const LIMIT_UNIT = 'results';
8
+
9
+ /** The find tool's timeline item: `pattern · path · N results` over Pi's listing lines. */
10
+ export function FindToolMessage(props: ToolMessageRenderProps) {
11
+ const view = findCallView(props.args);
12
+ return (
13
+ <MessageItem
14
+ tone={toolTone({ running: props.running, isError: props.isError })}
15
+ expandable={listingExpandable(props, LIMIT_KEY, LIMIT_UNIT)}
16
+ >
17
+ {({ expanded }) => (
18
+ <>
19
+ <MessageItemHeader title="find">
20
+ <span data-testid="tool-call-find" className="flex min-w-0 flex-1 items-center gap-2">
21
+ <span className="truncate text-doom-text">{view.primary}</span>
22
+ {view.details.length > 0 ? (
23
+ <span className="shrink-0 text-doom-faint">· {view.details.join(' · ')}</span>
24
+ ) : null}
25
+ </span>
26
+ </MessageItemHeader>
27
+ <ListingBody tool="find" limitKey={LIMIT_KEY} limitUnit={LIMIT_UNIT} expanded={expanded} props={props} />
28
+ </>
29
+ )}
30
+ </MessageItem>
31
+ );
32
+ }
@@ -0,0 +1,57 @@
1
+ import { MessageItemBody, MessageItemStatus } from '@agimon-ai/doompi-web-components';
2
+ import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
3
+ import { listResultView, resultText } from './builtinToolView.ts';
4
+
5
+ /**
6
+ * The body shared by find and ls: Pi's listing lines, collapsed to twenty
7
+ * until the item expands, with the limit warning its details carry.
8
+ */
9
+ export function ListingBody({
10
+ tool,
11
+ limitKey,
12
+ limitUnit,
13
+ expanded,
14
+ props,
15
+ }: {
16
+ tool: string;
17
+ limitKey: string;
18
+ limitUnit: string;
19
+ expanded: boolean;
20
+ props: ToolMessageRenderProps;
21
+ }) {
22
+ const { result, output, running, isError } = props;
23
+ if (isError) {
24
+ return (
25
+ <MessageItemBody data-testid={`tool-result-${tool}`} className="flex flex-col">
26
+ <MessageItemStatus tone="error">failed</MessageItemStatus>
27
+ <span className="whitespace-pre-wrap break-words text-doom-red">{resultText(result, output)}</span>
28
+ </MessageItemBody>
29
+ );
30
+ }
31
+ if (result === null) {
32
+ return running ? (
33
+ <MessageItemBody data-testid={`tool-result-${tool}`}>
34
+ <MessageItemStatus tone="running">running</MessageItemStatus>
35
+ </MessageItemBody>
36
+ ) : null;
37
+ }
38
+ const view = listResultView(result, output, expanded, limitKey, limitUnit);
39
+ if (view.lines.length === 0 && view.truncated === undefined) return null;
40
+ return (
41
+ <MessageItemBody data-testid={`tool-result-${tool}`} className="flex flex-col font-mono">
42
+ {view.lines.map((line, index) => (
43
+ <span key={`${String(index)}-${line}`} className="whitespace-pre-wrap break-words text-doom-text">
44
+ {line}
45
+ </span>
46
+ ))}
47
+ {view.hidden > 0 ? <MessageItemStatus expands>{view.hidden} more lines</MessageItemStatus> : null}
48
+ {view.truncated !== undefined ? <span className="text-doom-yellow">{view.truncated}</span> : null}
49
+ </MessageItemBody>
50
+ );
51
+ }
52
+
53
+ /** Whether the listing hides lines while collapsed, so the item can offer its toggle. */
54
+ export function listingExpandable(props: ToolMessageRenderProps, limitKey: string, limitUnit: string): boolean {
55
+ if (props.result === null || props.isError) return false;
56
+ return listResultView(props.result, props.output, false, limitKey, limitUnit).hidden > 0;
57
+ }
@@ -0,0 +1,32 @@
1
+ import { MessageItem, MessageItemHeader, toolTone } from '@agimon-ai/doompi-web-components';
2
+ import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
3
+ import { lsCallView } from './builtinToolView.ts';
4
+ import { ListingBody, listingExpandable } from './ListingBody.tsx';
5
+
6
+ const LIMIT_KEY = 'entryLimitReached';
7
+ const LIMIT_UNIT = 'entries';
8
+
9
+ /** The ls tool's timeline item: `path · N entries` over Pi's listing lines. */
10
+ export function LsToolMessage(props: ToolMessageRenderProps) {
11
+ const view = lsCallView(props.args);
12
+ return (
13
+ <MessageItem
14
+ tone={toolTone({ running: props.running, isError: props.isError })}
15
+ expandable={listingExpandable(props, LIMIT_KEY, LIMIT_UNIT)}
16
+ >
17
+ {({ expanded }) => (
18
+ <>
19
+ <MessageItemHeader title="ls">
20
+ <span data-testid="tool-call-ls" className="flex min-w-0 flex-1 items-center gap-2">
21
+ <span className="truncate text-doom-text">{view.primary}</span>
22
+ {view.details.length > 0 ? (
23
+ <span className="shrink-0 text-doom-faint">· {view.details.join(' · ')}</span>
24
+ ) : null}
25
+ </span>
26
+ </MessageItemHeader>
27
+ <ListingBody tool="ls" limitKey={LIMIT_KEY} limitUnit={LIMIT_UNIT} expanded={expanded} props={props} />
28
+ </>
29
+ )}
30
+ </MessageItem>
31
+ );
32
+ }
@@ -0,0 +1,61 @@
1
+ import {
2
+ MessageItem,
3
+ MessageItemBody,
4
+ MessageItemHeader,
5
+ MessageItemStatus,
6
+ toolTone,
7
+ } from '@agimon-ai/doompi-web-components';
8
+ import type { ToolMessageRenderProps } from '@agimon-ai/doompi-web-contracts';
9
+ import { resultText, writeCallView } from './builtinToolView.ts';
10
+
11
+ /**
12
+ * The write tool's timeline item: `path · N chars` in the header. Pi's write
13
+ * result is silent on success, so the body shows the numbered preview the
14
+ * TUI folds into its call: ten lines, or all of them once the item expands.
15
+ * A failure shows the tool's message in red instead.
16
+ */
17
+ export function WriteToolMessage({ args, result, output, running, isError }: ToolMessageRenderProps) {
18
+ const collapsed = writeCallView(args, false);
19
+ return (
20
+ <MessageItem tone={toolTone({ running, isError })} expandable={collapsed.hidden > 0}>
21
+ {({ expanded }) => {
22
+ const view = expanded ? writeCallView(args, true) : collapsed;
23
+ return (
24
+ <>
25
+ <MessageItemHeader title="write">
26
+ <span data-testid="tool-call-write" className="flex min-w-0 flex-1 items-center gap-2">
27
+ <span className="truncate text-doom-text">{view.path}</span>
28
+ <span className="shrink-0 text-doom-faint">· {view.size}</span>
29
+ </span>
30
+ </MessageItemHeader>
31
+ {isError ? (
32
+ <MessageItemBody data-testid="tool-result-write" className="flex flex-col">
33
+ <MessageItemStatus tone="error">failed</MessageItemStatus>
34
+ <span className="whitespace-pre-wrap break-words text-doom-red">{resultText(result, output)}</span>
35
+ </MessageItemBody>
36
+ ) : view.preview.length === 0 ? (
37
+ running ? (
38
+ <MessageItemBody data-testid="tool-result-write">
39
+ <MessageItemStatus tone="running">running</MessageItemStatus>
40
+ </MessageItemBody>
41
+ ) : null
42
+ ) : (
43
+ <MessageItemBody data-testid="tool-result-write" className="flex flex-col font-mono">
44
+ {view.preview.map((line) => (
45
+ <span key={line.number} className="flex gap-2">
46
+ <span className="shrink-0 text-right text-doom-faint" style={{ width: `${String(view.gutter)}ch` }}>
47
+ {line.number}
48
+ </span>
49
+ <span className="min-w-0 whitespace-pre-wrap break-words text-doom-text">{line.text}</span>
50
+ </span>
51
+ ))}
52
+ {view.hidden > 0 ? <MessageItemStatus expands>{view.hidden} more lines</MessageItemStatus> : null}
53
+ {running ? <MessageItemStatus tone="running">running</MessageItemStatus> : null}
54
+ </MessageItemBody>
55
+ )}
56
+ </>
57
+ );
58
+ }}
59
+ </MessageItem>
60
+ );
61
+ }
@@ -0,0 +1,127 @@
1
+ import type { ToolResultView } from '@agimon-ai/doompi-web-contracts';
2
+
3
+ /** The TUI's collapsed budgets: ten preview lines for a write, twenty for a listing. */
4
+ export const WRITE_COLLAPSED_LINES = 10;
5
+ export const LIST_COLLAPSED_LINES = 20;
6
+ /** Pi's default output cap, the size its truncation warning names when details omit one. */
7
+ const DEFAULT_MAX_BYTES = 50 * 1024;
8
+ const KIBIBYTE = 1024;
9
+
10
+ export interface NumberedLine {
11
+ readonly number: number;
12
+ readonly text: string;
13
+ }
14
+
15
+ export interface WriteCallView {
16
+ readonly path: string;
17
+ /** `N chars`, the size the TUI's call heading shows. */
18
+ readonly size: string;
19
+ readonly preview: readonly NumberedLine[];
20
+ readonly hidden: number;
21
+ /** Widest shown line number, in characters. */
22
+ readonly gutter: number;
23
+ }
24
+
25
+ export interface SearchCallView {
26
+ readonly primary: string;
27
+ readonly details: readonly string[];
28
+ }
29
+
30
+ export interface ListResultView {
31
+ readonly lines: readonly string[];
32
+ readonly hidden: number;
33
+ /** Pi's `[Truncated: ...]` warning, when the listing hit a limit. */
34
+ readonly truncated: string | undefined;
35
+ }
36
+
37
+ function compactDetails(values: ReadonlyArray<string | undefined>): string[] {
38
+ return values.filter((value): value is string => value !== undefined && value.length > 0);
39
+ }
40
+
41
+ function optionalCount(value: unknown, unit: string): string | undefined {
42
+ return typeof value === 'number' && Number.isFinite(value) ? `${value} ${unit}` : undefined;
43
+ }
44
+
45
+ function pathOrDot(value: unknown): string {
46
+ return typeof value === 'string' && value.length > 0 ? value : '.';
47
+ }
48
+
49
+ /** Pi's size formatter, so the web warning names the same figure the TUI does. */
50
+ export function formatSize(bytes: number): string {
51
+ if (bytes < KIBIBYTE) return `${bytes}B`;
52
+ if (bytes < KIBIBYTE * KIBIBYTE) return `${(bytes / KIBIBYTE).toFixed(1)}KB`;
53
+ return `${(bytes / (KIBIBYTE * KIBIBYTE)).toFixed(1)}MB`;
54
+ }
55
+
56
+ /** What the write call shows: the path, the content size, and a numbered preview of the file. */
57
+ export function writeCallView(args: Readonly<Record<string, unknown>>, expanded: boolean): WriteCallView {
58
+ const content = typeof args.content === 'string' ? args.content : '';
59
+ const path = typeof args.path === 'string' ? args.path : '';
60
+ const size = `${content.length.toLocaleString('en-US')} chars`;
61
+ if (content.length === 0) return { path, size, preview: [], hidden: 0, gutter: 1 };
62
+ const lines = content.replaceAll('\t', ' ').replaceAll('\r\n', '\n').split('\n');
63
+ while (lines.at(-1) === '') lines.pop();
64
+ const visible = expanded ? lines : lines.slice(0, WRITE_COLLAPSED_LINES);
65
+ const preview = visible.map((text, index) => ({ number: index + 1, text }));
66
+ return { path, size, preview, hidden: lines.length - visible.length, gutter: String(visible.length).length };
67
+ }
68
+
69
+ /** `pattern · path · N results`, as the find call heading lists them. */
70
+ export function findCallView(args: Readonly<Record<string, unknown>>): SearchCallView {
71
+ return {
72
+ primary: typeof args.pattern === 'string' ? args.pattern : '',
73
+ details: compactDetails([pathOrDot(args.path), optionalCount(args.limit, 'results')]),
74
+ };
75
+ }
76
+
77
+ /** `path · N entries`, as the ls call heading lists them. */
78
+ export function lsCallView(args: Readonly<Record<string, unknown>>): SearchCallView {
79
+ return { primary: pathOrDot(args.path), details: compactDetails([optionalCount(args.limit, 'entries')]) };
80
+ }
81
+
82
+ /** The result's text, from its blocks when the view exists and from the joined output otherwise. */
83
+ export function resultText(result: ToolResultView | null, output: string): string {
84
+ if (result === null) return output;
85
+ return result.content
86
+ .flatMap((block) => {
87
+ const record = typeof block === 'object' && block !== null ? (block as { type?: unknown; text?: unknown }) : {};
88
+ return record.type === 'text' && typeof record.text === 'string' ? [record.text] : [];
89
+ })
90
+ .join('\n');
91
+ }
92
+
93
+ /**
94
+ * A find or ls listing the way Pi's own renderer shows it: the lines,
95
+ * collapsed to twenty, and the limit warning its details carry. `limitKey`
96
+ * names the per-tool counter (resultLimitReached, entryLimitReached).
97
+ */
98
+ export function listResultView(
99
+ result: ToolResultView | null,
100
+ output: string,
101
+ expanded: boolean,
102
+ limitKey: string,
103
+ limitUnit: string,
104
+ ): ListResultView {
105
+ const text = resultText(result, output).trim();
106
+ const all = text.length === 0 ? [] : text.split('\n');
107
+ const lines = expanded ? all : all.slice(0, LIST_COLLAPSED_LINES);
108
+ const details =
109
+ typeof result?.details === 'object' && result.details !== null ? (result.details as Record<string, unknown>) : {};
110
+ const limit = details[limitKey];
111
+ const truncation =
112
+ typeof details.truncation === 'object' && details.truncation !== null
113
+ ? (details.truncation as Record<string, unknown>)
114
+ : undefined;
115
+ const warnings: string[] = [];
116
+ if (typeof limit === 'number' && limit > 0) warnings.push(`${limit} ${limitUnit} limit`);
117
+ if (truncation?.truncated === true) {
118
+ warnings.push(
119
+ `${formatSize(typeof truncation.maxBytes === 'number' ? truncation.maxBytes : DEFAULT_MAX_BYTES)} limit`,
120
+ );
121
+ }
122
+ return {
123
+ lines,
124
+ hidden: all.length - lines.length,
125
+ truncated: warnings.length > 0 ? `[Truncated: ${warnings.join(', ')}]` : undefined,
126
+ };
127
+ }
package/web/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
2
+ import { FindToolMessage } from './FindToolMessage.tsx';
3
+ import { LsToolMessage } from './LsToolMessage.tsx';
4
+ import { WriteToolMessage } from './WriteToolMessage.tsx';
5
+
6
+ /**
7
+ * This package's cockpit presence: timeline cards for the Pi builtins whose
8
+ * UI this package re-registers and no default package replaces. read, edit,
9
+ * and grep belong to doompi-read, doompi-edit, and doompi-grep, which claim
10
+ * them with their own cards.
11
+ */
12
+ export const webPlugin = defineWebPlugin({
13
+ id: 'builtin-tools',
14
+ toolRenderers: [
15
+ { tools: ['write'], message: WriteToolMessage },
16
+ { tools: ['find'], message: FindToolMessage },
17
+ { tools: ['ls'], message: LsToolMessage },
18
+ ],
19
+ });
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noImplicitAny": true,
10
+ "noUnusedLocals": true,
11
+ "noUnusedParameters": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noEmit": true,
15
+ "resolveJsonModule": true,
16
+ "esModuleInterop": true,
17
+ "skipLibCheck": true,
18
+ "allowImportingTsExtensions": true,
19
+ "forceConsistentCasingInFileNames": true,
20
+ "types": ["node"]
21
+ },
22
+ "include": [".", "../src/types"]
23
+ }