@agimon-ai/doompi-read 0.0.1-alpha.1 → 0.0.1-alpha.10

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
@@ -15,7 +15,7 @@ people and preserves syntax highlighting. Image reads retain Pi's native attachm
15
15
  ## Requirements
16
16
 
17
17
  - Node.js 22.19.0 or newer
18
- - Pi 0.84.2 and Pi TUI 0.84.2
18
+ - Pi 0.84.3 and Pi TUI 0.84.3
19
19
 
20
20
  ## Install
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-read",
3
- "version": "0.0.1-alpha.1",
3
+ "version": "0.0.1-alpha.10",
4
4
  "description": "Snapshot-bound hashline read tool for Pi and DoomPi.",
5
5
  "keywords": [
6
6
  "ai",
@@ -14,10 +14,19 @@
14
14
  "read"
15
15
  ],
16
16
  "homepage": "https://agimon.ai",
17
+ "bugs": {
18
+ "url": "https://github.com/AgiFlow/doompi/issues"
19
+ },
17
20
  "license": "MIT",
18
21
  "author": "Vuong Ngo",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/AgiFlow/doompi.git",
25
+ "directory": "packages/default/doompi-read"
26
+ },
19
27
  "files": [
20
- "dist"
28
+ "dist",
29
+ "web"
21
30
  ],
22
31
  "type": "module",
23
32
  "main": "./dist/index.cjs",
@@ -42,22 +51,28 @@
42
51
  "dependencies": {
43
52
  "@deepseek-ai/cordis": "4.0.1",
44
53
  "typebox": "1.3.16",
45
- "@agimon-ai/doompi-hashline": "0.0.1-alpha.1",
46
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.29",
47
- "@agimon-ai/doompi-ui": "0.0.1-alpha.29"
54
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.38",
55
+ "@agimon-ai/doompi-hashline": "0.0.1-alpha.10",
56
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.38",
57
+ "@agimon-ai/doompi-web-components": "0.0.0",
58
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.1"
48
59
  },
49
60
  "devDependencies": {
50
- "@earendil-works/pi-coding-agent": "0.84.2",
51
- "@earendil-works/pi-tui": "0.84.2",
61
+ "@earendil-works/pi-coding-agent": "0.84.3",
62
+ "@earendil-works/pi-tui": "0.84.3",
63
+ "@tanstack/react-store": "0.11.1",
64
+ "@tanstack/store": "0.11.1",
52
65
  "@types/node": "26.2.0",
66
+ "@types/react": "19.2.7",
53
67
  "@vitest/coverage-v8": "4.1.11",
68
+ "react": "19.2.8",
54
69
  "tsdown": "0.22.14",
55
70
  "typescript": "7.0.2",
56
71
  "vitest": "4.1.11"
57
72
  },
58
73
  "peerDependencies": {
59
- "@earendil-works/pi-coding-agent": "0.84.2",
60
- "@earendil-works/pi-tui": "0.84.2"
74
+ "@earendil-works/pi-coding-agent": "0.84.3",
75
+ "@earendil-works/pi-tui": "0.84.3"
61
76
  },
62
77
  "peerDependenciesMeta": {
63
78
  "@earendil-works/pi-coding-agent": {
@@ -70,6 +85,11 @@
70
85
  "engines": {
71
86
  "node": ">=22.19.0"
72
87
  },
88
+ "doompiWeb": {
89
+ "pluginId": "read",
90
+ "channels": [],
91
+ "client": "./web/index.ts"
92
+ },
73
93
  "pi": {
74
94
  "extensions": [
75
95
  "./dist/extensions/pi.mjs"
@@ -78,7 +98,7 @@
78
98
  "scripts": {
79
99
  "build": "tsdown",
80
100
  "test": "vitest --run",
81
- "typecheck": "tsc --noEmit",
101
+ "typecheck": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json",
82
102
  "lint": "oxlint . && oxfmt . --check",
83
103
  "fixcode": "oxlint . --fix && oxfmt ."
84
104
  }
@@ -0,0 +1,47 @@
1
+ import type { PresentedLine } from './hashlineView.ts';
2
+
3
+ /**
4
+ * The anchored body shared by hashline results: a file heading per group, a
5
+ * right-aligned line-number gutter, and a `>>` marker on grep matches. The
6
+ * gutter width is a style, not a class, because it follows the widest number.
7
+ */
8
+ export function HashlineLines({ lines, gutter }: { lines: readonly PresentedLine[]; gutter: number }) {
9
+ return (
10
+ <div className="flex flex-col font-mono">
11
+ {lines.map((line, index) => {
12
+ if (line.type === 'file') {
13
+ return (
14
+ <span key={index} className="mt-1 truncate text-doom-faint first:mt-0">
15
+ {line.path}
16
+ </span>
17
+ );
18
+ }
19
+ if (line.type === 'plain') {
20
+ return (
21
+ <span key={index} className="whitespace-pre-wrap break-words text-doom-dim">
22
+ {line.text}
23
+ </span>
24
+ );
25
+ }
26
+ const { value } = line;
27
+ return (
28
+ <span key={index} className="flex gap-2">
29
+ {value.marker !== undefined ? (
30
+ <span className={`w-4 shrink-0 ${value.marker === 'match' ? 'text-doom-blue' : 'text-doom-faint'}`}>
31
+ {value.marker === 'match' ? '>>' : ''}
32
+ </span>
33
+ ) : null}
34
+ <span className="shrink-0 text-right text-doom-faint" style={{ width: `${gutter}ch` }}>
35
+ {value.line}
36
+ </span>
37
+ <span
38
+ className={`min-w-0 whitespace-pre-wrap break-words ${value.marker === 'match' ? 'text-doom-hi' : 'text-doom-text'}`}
39
+ >
40
+ {value.content}
41
+ </span>
42
+ </span>
43
+ );
44
+ })}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,64 @@
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 { readCallView } from './readToolView.ts';
10
+ import { HashlineLines } from './HashlineLines.tsx';
11
+ import { hashlineBody, resultTextLines } from './hashlineView.ts';
12
+
13
+ /**
14
+ * The read tool's timeline item, the web half of its renderCall and
15
+ * renderResult: the call summary in the header and the anchored lines with
16
+ * a line-number gutter in the body, collapsed to the TUI's budget until the
17
+ * item expands. A failure shows the tool's message in red instead.
18
+ */
19
+ export function ReadToolMessage({ args, result, output, running, isError }: ToolMessageRenderProps) {
20
+ const view = readCallView(args);
21
+ const collapsed = result === null || isError ? null : hashlineBody(result, output, 'read', false);
22
+ return (
23
+ <MessageItem tone={toolTone({ running, isError })} expandable={collapsed !== null && collapsed.hidden > 0}>
24
+ {({ expanded }) => {
25
+ const body =
26
+ result === null || isError ? null : expanded ? hashlineBody(result, output, 'read', true) : collapsed;
27
+ return (
28
+ <>
29
+ <MessageItemHeader title="read">
30
+ <span data-testid="tool-call-read" className="flex min-w-0 flex-1 items-center gap-2">
31
+ <span className="truncate text-doom-text">{view.path}</span>
32
+ {view.details.length > 0 ? (
33
+ <span className="shrink-0 text-doom-faint">· {view.details.join(' · ')}</span>
34
+ ) : null}
35
+ </span>
36
+ </MessageItemHeader>
37
+ {isError ? (
38
+ <MessageItemBody data-testid="tool-result-read" className="flex flex-col">
39
+ <MessageItemStatus tone="error">failed</MessageItemStatus>
40
+ {resultTextLines(result, output).map((line, index) => (
41
+ <span key={`${String(index)}-${line}`} className="whitespace-pre-wrap break-words text-doom-red">
42
+ {line}
43
+ </span>
44
+ ))}
45
+ </MessageItemBody>
46
+ ) : body === null ? (
47
+ running ? (
48
+ <MessageItemBody data-testid="tool-result-read">
49
+ <MessageItemStatus tone="running">running</MessageItemStatus>
50
+ </MessageItemBody>
51
+ ) : null
52
+ ) : body.shown.length === 0 && body.notice === undefined ? null : (
53
+ <MessageItemBody data-testid="tool-result-read" className="flex flex-col gap-1">
54
+ <HashlineLines lines={body.shown} gutter={body.gutter} />
55
+ {body.hidden > 0 ? <MessageItemStatus expands>{body.hidden} more lines</MessageItemStatus> : null}
56
+ {body.notice !== undefined ? <span className="text-doom-faint">{body.notice}</span> : null}
57
+ </MessageItemBody>
58
+ )}
59
+ </>
60
+ );
61
+ }}
62
+ </MessageItem>
63
+ );
64
+ }
@@ -0,0 +1,138 @@
1
+ import type { ToolResultView } from '@agimon-ai/doompi-web-contracts';
2
+
3
+ /**
4
+ * The hashline wire format, parsed the way the TUI's hashlineRendering does:
5
+ * `@file path#tag` opens a file, `N#abc|content` is an anchored line, and a
6
+ * `>> ` or three-space prefix marks grep matches and their context. The
7
+ * patterns are copied from doompi-hashline because a cockpit plugin may only
8
+ * import its own pure modules and the web contract.
9
+ */
10
+ const FILE_HEADER_PATTERN = /^@file (.+)#([A-Za-z0-9_-]{8})$/u;
11
+ const TAGGED_LINE_PATTERN = /^(>> | {3})?(\d+)#[a-z]{3}\|(.*)$/u;
12
+ const MATCH_PREFIX = '>> ';
13
+ const CONTEXT_PREFIX = ' ';
14
+ const IMAGE_PLACEHOLDER = '[Image attached]';
15
+
16
+ export const READ_COLLAPSED_LINES = 10;
17
+ export const GREP_COLLAPSED_LINES = 15;
18
+
19
+ export type HashlineResultKind = 'read' | 'grep';
20
+ export type TaggedLineMarker = 'match' | 'context';
21
+
22
+ export interface TaggedLine {
23
+ readonly line: number;
24
+ readonly content: string;
25
+ readonly marker: TaggedLineMarker | undefined;
26
+ }
27
+
28
+ export type PresentedLine =
29
+ | { readonly type: 'file'; readonly path: string }
30
+ | { readonly type: 'tagged'; readonly value: TaggedLine }
31
+ | { readonly type: 'plain'; readonly text: string };
32
+
33
+ export interface HashlineBody {
34
+ readonly shown: readonly PresentedLine[];
35
+ /** Lines the collapsed view holds back; zero once expanded. */
36
+ readonly hidden: number;
37
+ /** The bracketed pagination or compaction note the tool appends after its lines. */
38
+ readonly notice: string | undefined;
39
+ /** Widest line number among the shown anchored lines, in characters. */
40
+ readonly gutter: number;
41
+ }
42
+
43
+ interface TextBlock {
44
+ readonly type: string;
45
+ readonly text?: string;
46
+ }
47
+
48
+ function isTextBlock(block: unknown): block is TextBlock {
49
+ return typeof block === 'object' && block !== null && typeof (block as TextBlock).type === 'string';
50
+ }
51
+
52
+ /**
53
+ * The result's text as lines: tabs widened, CRLF folded, trailing blanks
54
+ * dropped. A result with no text but an image block reads as the TUI's
55
+ * placeholder. The joined `output` stands in when no result view exists.
56
+ */
57
+ export function resultTextLines(result: ToolResultView | null, output: string): string[] {
58
+ const blocks = result?.content.filter(isTextBlock) ?? [];
59
+ const text =
60
+ result === null ? output : blocks.flatMap((block) => (block.type === 'text' ? [block.text ?? ''] : [])).join('\n');
61
+ if (text.length === 0) {
62
+ return blocks.some((block) => block.type === 'image') ? [IMAGE_PLACEHOLDER] : [];
63
+ }
64
+ const lines = text.replaceAll('\t', ' ').replaceAll('\r\n', '\n').split('\n');
65
+ while (lines.at(-1) === '') lines.pop();
66
+ return lines;
67
+ }
68
+
69
+ /** Detaches the tool's trailing `[...]` note so the body numbers file content only. */
70
+ export function takeTrailingNotice(lines: string[]): string | undefined {
71
+ const last = lines.at(-1);
72
+ if (last === undefined || !last.startsWith('[') || !last.endsWith(']')) return undefined;
73
+ lines.pop();
74
+ while (lines.at(-1) === '') lines.pop();
75
+ return last;
76
+ }
77
+
78
+ export function parseTaggedLine(value: string): TaggedLine | undefined {
79
+ const match = TAGGED_LINE_PATTERN.exec(value);
80
+ const lineText = match?.[2];
81
+ const content = match?.[3];
82
+ if (lineText === undefined || content === undefined) return undefined;
83
+ const line = Number.parseInt(lineText, 10);
84
+ if (!Number.isSafeInteger(line) || line < 1) return undefined;
85
+ const prefix = match?.[1];
86
+ const marker = prefix === MATCH_PREFIX ? 'match' : prefix === CONTEXT_PREFIX ? 'context' : undefined;
87
+ return { line, content, marker };
88
+ }
89
+
90
+ export function parseFileHeader(value: string): string | undefined {
91
+ return FILE_HEADER_PATTERN.exec(value)?.[1];
92
+ }
93
+
94
+ /**
95
+ * Lines as the card presents them. A read shows one file, so its header is
96
+ * dropped; a grep groups anchored matches under each file's path.
97
+ */
98
+ export function presentHashlineLines(lines: readonly string[], kind: HashlineResultKind): PresentedLine[] {
99
+ const presented: PresentedLine[] = [];
100
+ for (const line of lines) {
101
+ const path = parseFileHeader(line);
102
+ if (path !== undefined) {
103
+ if (kind !== 'read') presented.push({ type: 'file', path });
104
+ continue;
105
+ }
106
+ const tagged = parseTaggedLine(line);
107
+ if (tagged !== undefined) {
108
+ presented.push({ type: 'tagged', value: tagged });
109
+ continue;
110
+ }
111
+ presented.push({ type: 'plain', text: line });
112
+ }
113
+ return presented;
114
+ }
115
+
116
+ /** The body a card shows for one result, collapsed to the kind's line budget unless expanded. */
117
+ export function hashlineBody(
118
+ result: ToolResultView | null,
119
+ output: string,
120
+ kind: HashlineResultKind,
121
+ expanded: boolean,
122
+ ): HashlineBody {
123
+ const lines = resultTextLines(result, output);
124
+ const notice = takeTrailingNotice(lines);
125
+ const presented = presentHashlineLines(lines, kind);
126
+ const budget = kind === 'read' ? READ_COLLAPSED_LINES : GREP_COLLAPSED_LINES;
127
+ const shown = expanded ? presented : presented.slice(0, budget);
128
+ const gutter = shown.reduce(
129
+ (widest, line) => (line.type === 'tagged' ? Math.max(widest, String(line.value.line).length) : widest),
130
+ 1,
131
+ );
132
+ return { shown, hidden: presented.length - shown.length, notice, gutter };
133
+ }
134
+
135
+ /** The `key · key` detail list beside a call's primary argument, with absent values dropped. */
136
+ export function compactDetails(values: ReadonlyArray<string | undefined>): string[] {
137
+ return values.filter((value): value is string => value !== undefined && value.length > 0);
138
+ }
package/web/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
2
+ import { ReadToolMessage } from './ReadToolMessage.tsx';
3
+
4
+ /**
5
+ * This package's cockpit presence: the read tool's timeline card, the web
6
+ * half of the TUI's renderCall and renderResult for hashline reads.
7
+ */
8
+ export const webPlugin = defineWebPlugin({
9
+ id: 'read',
10
+ toolRenderers: [{ tools: ['read'], message: ReadToolMessage }],
11
+ });
@@ -0,0 +1,24 @@
1
+ import { compactDetails } from './hashlineView.ts';
2
+
3
+ export interface ReadCallView {
4
+ readonly path: string;
5
+ /** `from N` and `N lines`, as the TUI's call heading lists them. */
6
+ readonly details: readonly string[];
7
+ }
8
+
9
+ function optionalNumber(value: unknown): number | undefined {
10
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
11
+ }
12
+
13
+ /** What the read call heading shows: the path, then the offset and limit when given. */
14
+ export function readCallView(args: Readonly<Record<string, unknown>>): ReadCallView {
15
+ const offset = optionalNumber(args.offset);
16
+ const limit = optionalNumber(args.limit);
17
+ return {
18
+ path: typeof args.path === 'string' ? args.path : '',
19
+ details: compactDetails([
20
+ offset === undefined ? undefined : `from ${offset}`,
21
+ limit === undefined ? undefined : `${limit} lines`,
22
+ ]),
23
+ };
24
+ }
@@ -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
+ }