@meowlynxsea/koi 0.1.0
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/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- package/src/types/cross-spawn.d.ts +24 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meowlynxsea/koi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A coding agent built on Pi SDK with TUI + Bun runtime",
|
|
5
|
+
"module": "src/main.tsx",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "GPL-3.0-only",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/meowlynxsea/koi"
|
|
11
|
+
},
|
|
12
|
+
"files": ["dist", "bin", "src", "NOTICE"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "bun run src/main.tsx",
|
|
15
|
+
"build": "bun build src/main.tsx --outdir dist --target bun",
|
|
16
|
+
"check": "tsc --noEmit",
|
|
17
|
+
"lint": "eslint src/",
|
|
18
|
+
"lint:fix": "eslint src/ --fix"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"koi": "./bin/koi"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "latest",
|
|
25
|
+
"@types/react": "^19.2.14",
|
|
26
|
+
"@types/turndown": "^5.0.6",
|
|
27
|
+
"eslint": "^10.3.0",
|
|
28
|
+
"globals": "^17.6.0",
|
|
29
|
+
"typescript": "^5",
|
|
30
|
+
"typescript-eslint": "^8.59.2"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@mariozechner/pi-agent-core": "0.73.0",
|
|
34
|
+
"@mariozechner/pi-ai": "0.73.0",
|
|
35
|
+
"@mariozechner/pi-coding-agent": "0.73.0",
|
|
36
|
+
"@mariozechner/pi-tui": "0.73.0",
|
|
37
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
38
|
+
"@opentui-ui/dialog": "latest",
|
|
39
|
+
"@opentui/core": "latest",
|
|
40
|
+
"@opentui/react": "latest",
|
|
41
|
+
"@types/diff": "^8.0.0",
|
|
42
|
+
"axios": "^1.16.0",
|
|
43
|
+
"bun": "^1.3.12",
|
|
44
|
+
"commander": "^14.0.3",
|
|
45
|
+
"diff": "^9.0.0",
|
|
46
|
+
"fast-glob": "^3.3.3",
|
|
47
|
+
"jimp": "^1.6.1",
|
|
48
|
+
"react": "^19.2.6",
|
|
49
|
+
"turndown": "^7.2.4"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Checker — 权限检查器
|
|
3
|
+
*
|
|
4
|
+
* Supports three decisions per tool call:
|
|
5
|
+
* allow → proceed with execution
|
|
6
|
+
* deny → return an error tool result immediately
|
|
7
|
+
* ask → show a confirmation modal and wait for user input
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { statSync } from "fs";
|
|
11
|
+
import { isPreapprovedDomain, isDangerousHost } from "../tools/webfetch-domains.js";
|
|
12
|
+
|
|
13
|
+
export type PermissionDecision = "allow" | "deny" | "ask";
|
|
14
|
+
|
|
15
|
+
export interface PermissionRule {
|
|
16
|
+
/** Match specific tool name, or omit to match all */
|
|
17
|
+
toolName?: string;
|
|
18
|
+
/** Regex to match against stringified args (e.g. command string) */
|
|
19
|
+
pattern?: RegExp;
|
|
20
|
+
/** Deny specific file paths (absolute or relative) */
|
|
21
|
+
denyPaths?: string[];
|
|
22
|
+
/** Result of this rule */
|
|
23
|
+
decision: PermissionDecision;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PermissionCheckResult {
|
|
28
|
+
decision: PermissionDecision;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Static Rule Data
|
|
34
|
+
*
|
|
35
|
+
* DANGEROUS_BASH_PATTERNS: commands that can destroy data or rewrite git history.
|
|
36
|
+
* BLOCKED_PATHS: device/special files that should never be read or written.
|
|
37
|
+
* SENSITIVE_FILE_PATTERNS: credentials, keys, env files — editing them requires confirmation.
|
|
38
|
+
* Tool sets are grouped as constants so the default/fallback logic stays readable.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const DANGEROUS_BASH_PATTERNS = [
|
|
42
|
+
/\brm\s+-(rf|fr|r\s+f|f\s+r)\b/i,
|
|
43
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
44
|
+
/\bgit\s+push\s+--force\b/i,
|
|
45
|
+
/\bgit\s+clean\s+-[fd]\b/i,
|
|
46
|
+
/\bgit\s+checkout\s+--\s+\.\b/i,
|
|
47
|
+
/\bgit\s+restore\s+--\s+\.\b/i,
|
|
48
|
+
/\b(git\s+stash\s+drop|git\s+stash\s+clear)\b/i,
|
|
49
|
+
/\b(git\s+branch\s+-D|git\s+branch\s+--delete)\b/i,
|
|
50
|
+
/\b(git\s+(commit|push|merge)\s+--no-verify)\b/i,
|
|
51
|
+
/\b(git\s+commit\s+--amend)\b/i,
|
|
52
|
+
/\bDROP\s+TABLE\b/i,
|
|
53
|
+
/\bTRUNCATE\s+TABLE\b/i,
|
|
54
|
+
/\bDELETE\s+FROM\b/i,
|
|
55
|
+
/\bkubectl\s+delete\b/i,
|
|
56
|
+
/\bterraform\s+destroy\b/i,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const BLOCKED_PATHS = [
|
|
60
|
+
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
|
|
61
|
+
"/dev/stdin", "/dev/tty", "/dev/console", "/dev/stdout", "/dev/stderr",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const SENSITIVE_FILE_PATTERNS = [
|
|
65
|
+
/\.env$/i, /\.env\./i, /secret/i, /private.*key/i,
|
|
66
|
+
/id_rsa/i, /id_ed25519/i, /\.ssh\//i, /credentials/i,
|
|
67
|
+
/token/i, /password/i,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const ALWAYS_ALLOW_TOOLS = new Set(["taskCreate", "taskGet", "taskList", "taskUpdate"]);
|
|
71
|
+
const PATH_TOOLS = new Set(["read", "bash", "edit", "write"]);
|
|
72
|
+
const DESTRUCTIVE_TOOLS = new Set(["bash", "write"]);
|
|
73
|
+
const WRITE_TOOLS = new Set(["edit", "write"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* String & Path Helpers
|
|
77
|
+
*
|
|
78
|
+
* stringifyArgs normalizes tool arguments so regex rules can match against a plain string.
|
|
79
|
+
* extractPath is a convenience helper because different tools use "path" vs "file_path" keys.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
export function isDangerousBashCommand(command: string): boolean {
|
|
83
|
+
return DANGEROUS_BASH_PATTERNS.some((p) => p.test(command));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stringifyArgs(toolName: string, args: unknown): string {
|
|
87
|
+
if (typeof args === "string") return args;
|
|
88
|
+
if (args && typeof args === "object") {
|
|
89
|
+
const obj = args as Record<string, unknown>;
|
|
90
|
+
if (toolName === "bash" && typeof obj["command"] === "string") return obj["command"];
|
|
91
|
+
if (typeof obj["path"] === "string") return obj["path"];
|
|
92
|
+
if (typeof obj["file_path"] === "string") return obj["file_path"];
|
|
93
|
+
return JSON.stringify(args);
|
|
94
|
+
}
|
|
95
|
+
return String(args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function matchesBlockedPath(path: string): boolean {
|
|
99
|
+
const normalized = path.toLowerCase().replace(/\\/g, "/");
|
|
100
|
+
return BLOCKED_PATHS.some((blocked) => normalized.startsWith(blocked.toLowerCase()));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isSensitiveFile(path: string): boolean {
|
|
104
|
+
return SENSITIVE_FILE_PATTERNS.some((p) => p.test(path));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractPath(args: unknown): string | undefined {
|
|
108
|
+
const obj = args as Record<string, unknown> | undefined;
|
|
109
|
+
if (typeof obj?.["path"] === "string") return obj["path"];
|
|
110
|
+
if (typeof obj?.["file_path"] === "string") return obj["file_path"];
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Tool-Specific Checkers
|
|
116
|
+
*
|
|
117
|
+
* Each checker returns a PermissionCheckResult when it matches, or null to let the next checker run.
|
|
118
|
+
* They are evaluated in order (toolCheckers array below) — first match wins.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
type ToolChecker = (toolName: string, args: unknown, argStr: string) => PermissionCheckResult | null;
|
|
122
|
+
|
|
123
|
+
/** Blocks access to device/special paths (e.g. /dev/zero) for read/bash/edit/write tools. */
|
|
124
|
+
const checkBlockedPaths: ToolChecker = (toolName, args) => {
|
|
125
|
+
if (!PATH_TOOLS.has(toolName)) return null;
|
|
126
|
+
const path = extractPath(args);
|
|
127
|
+
if (path && matchesBlockedPath(path)) {
|
|
128
|
+
return { decision: "deny", reason: `Access to device/special path blocked: ${path}` };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Flags destructive bash commands (rm -rf, git reset --hard, DROP TABLE, etc.) for confirmation. */
|
|
134
|
+
const checkDangerousBash: ToolChecker = (toolName, _args, argStr) => {
|
|
135
|
+
if (toolName !== "bash") return null;
|
|
136
|
+
if (isDangerousBashCommand(argStr)) {
|
|
137
|
+
return {
|
|
138
|
+
decision: "ask",
|
|
139
|
+
reason: `Destructive command detected: "${argStr.trim()}". Confirm to proceed.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Requires confirmation before editing credentials, keys, or .env files. */
|
|
146
|
+
const checkSensitiveFiles: ToolChecker = (toolName, args) => {
|
|
147
|
+
if (!WRITE_TOOLS.has(toolName)) return null;
|
|
148
|
+
const path = extractPath(args);
|
|
149
|
+
if (path && isSensitiveFile(path)) {
|
|
150
|
+
return { decision: "ask", reason: `Editing sensitive file: ${path}. Confirm to proceed.` };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Asks before reading files larger than 1 GiB (to avoid accidental OOM / long blocks). */
|
|
156
|
+
const checkLargeFileRead: ToolChecker = (toolName, args) => {
|
|
157
|
+
if (toolName !== "read") return null;
|
|
158
|
+
const path = extractPath(args);
|
|
159
|
+
if (!path) return null;
|
|
160
|
+
try {
|
|
161
|
+
const stats = statSync(path);
|
|
162
|
+
if (stats.size > 1024 * 1024 * 1024) {
|
|
163
|
+
return { decision: "ask", reason: `File is very large (${(stats.size / 1024 / 1024).toFixed(0)} MiB). Confirm to read.` };
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Don't trigger permission prompt for stat failures (e.g., file not found).
|
|
167
|
+
// The read tool itself will handle file-not-found errors with a proper error message.
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Blocks dangerous hosts (localhost, IPs) and asks for non-preapproved domains. */
|
|
174
|
+
const checkWebFetch: ToolChecker = (_toolName, args) => {
|
|
175
|
+
const url = (args as Record<string, unknown>)?.["url"];
|
|
176
|
+
if (typeof url !== "string") return null;
|
|
177
|
+
try {
|
|
178
|
+
const parsed = new URL(url);
|
|
179
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
180
|
+
if (isDangerousHost(hostname)) {
|
|
181
|
+
return { decision: "deny", reason: `Access to local/IP/internal host blocked: ${hostname}` };
|
|
182
|
+
}
|
|
183
|
+
if (!isPreapprovedDomain(url)) {
|
|
184
|
+
return { decision: "ask", reason: `Fetching from non-preapproved domain: ${hostname}. Confirm to proceed.` };
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
return { decision: "deny", reason: "Invalid URL format" };
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const toolCheckers: ToolChecker[] = [
|
|
193
|
+
checkBlockedPaths,
|
|
194
|
+
checkDangerousBash,
|
|
195
|
+
checkSensitiveFiles,
|
|
196
|
+
checkLargeFileRead,
|
|
197
|
+
checkWebFetch,
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Main API
|
|
202
|
+
*
|
|
203
|
+
* Evaluates toolCheckers in order. If none match:
|
|
204
|
+
* • read-only tools → allow
|
|
205
|
+
* • destructive tools (bash, write) → ask
|
|
206
|
+
* • edit → allow (already vetted by checkSensitiveFiles if needed)
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check permission for a tool call.
|
|
211
|
+
*
|
|
212
|
+
* Rules are evaluated in order; the first matching rule wins.
|
|
213
|
+
* If no rule matches, destructive tools default to "ask",
|
|
214
|
+
* read-only tools default to "allow".
|
|
215
|
+
*/
|
|
216
|
+
export function checkPermission(
|
|
217
|
+
toolName: string,
|
|
218
|
+
args: unknown,
|
|
219
|
+
_customRules?: PermissionRule[]
|
|
220
|
+
): PermissionCheckResult {
|
|
221
|
+
// Task management tools are in-memory only — no permission needed
|
|
222
|
+
if (ALWAYS_ALLOW_TOOLS.has(toolName)) {
|
|
223
|
+
return { decision: "allow" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const argStr = stringifyArgs(toolName, args);
|
|
227
|
+
|
|
228
|
+
for (const checker of toolCheckers) {
|
|
229
|
+
const result = checker(toolName, args, argStr);
|
|
230
|
+
if (result) return result;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Default: read-only allow, destructive ask
|
|
234
|
+
const isDestructive = DESTRUCTIVE_TOOLS.has(toolName);
|
|
235
|
+
if (isDestructive) {
|
|
236
|
+
return { decision: "ask", reason: `${toolName} is a destructive operation. Confirm to proceed.` };
|
|
237
|
+
}
|
|
238
|
+
return { decision: "allow" };
|
|
239
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure utility functions for message extraction, transformation, and reconstruction.
|
|
5
|
+
* These functions are designed to be tree-shakable and independently testable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
|
|
9
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
10
|
+
import type { UIMessage } from "../../tui/components/chat-panel.js";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Types
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
interface ThinkingBlock {
|
|
17
|
+
type: "thinking";
|
|
18
|
+
thinking: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CustomPlanMessage {
|
|
22
|
+
role: "custom";
|
|
23
|
+
customType: "plan";
|
|
24
|
+
content: string | unknown[];
|
|
25
|
+
display: boolean;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// ID Generation
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generates a collision-resistant ID for UI message keys within a single session.
|
|
35
|
+
*/
|
|
36
|
+
export function generateId(prefix: string): string {
|
|
37
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Type Guards
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
export function isAssistantMessage(msg: unknown): msg is AssistantMessage {
|
|
45
|
+
return (
|
|
46
|
+
typeof msg === "object" &&
|
|
47
|
+
msg !== null &&
|
|
48
|
+
"role" in msg &&
|
|
49
|
+
(msg as Record<string, unknown>)["role"] === "assistant"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isUserMessage(msg: unknown): msg is UserMessage {
|
|
54
|
+
return (
|
|
55
|
+
typeof msg === "object" &&
|
|
56
|
+
msg !== null &&
|
|
57
|
+
"role" in msg &&
|
|
58
|
+
(msg as Record<string, unknown>)["role"] === "user"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isThinkingBlock(block: { type: string }): block is ThinkingBlock {
|
|
63
|
+
return block.type === "thinking" && "thinking" in block;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isCustomPlanMessage(msg: unknown): msg is CustomPlanMessage {
|
|
67
|
+
return (
|
|
68
|
+
typeof msg === "object" &&
|
|
69
|
+
msg !== null &&
|
|
70
|
+
"role" in msg &&
|
|
71
|
+
(msg as unknown as Record<string, unknown>)["role"] === "custom" &&
|
|
72
|
+
"customType" in msg &&
|
|
73
|
+
(msg as unknown as Record<string, unknown>)["customType"] === "plan"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Content Extraction
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
export function getUserMessageContent(msg: UserMessage): string {
|
|
82
|
+
if (typeof msg.content === "string") {
|
|
83
|
+
return msg.content;
|
|
84
|
+
}
|
|
85
|
+
return msg.content
|
|
86
|
+
.filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
|
|
87
|
+
.map((b) => b.text)
|
|
88
|
+
.join("");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function extractCustomPlanContent(msg: { content: string | unknown[] }): string {
|
|
92
|
+
if (typeof msg.content === "string") return msg.content;
|
|
93
|
+
if (Array.isArray(msg.content)) {
|
|
94
|
+
return msg.content
|
|
95
|
+
.filter((c): c is { type: "text"; text: string } =>
|
|
96
|
+
typeof c === "object" && c !== null && "type" in c && (c as unknown as Record<string, unknown>)["type"] === "text"
|
|
97
|
+
)
|
|
98
|
+
.map((c) => c.text)
|
|
99
|
+
.join("");
|
|
100
|
+
}
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function extractTextAndThinking(msg: AssistantMessage): {
|
|
105
|
+
text: string;
|
|
106
|
+
thinking: string;
|
|
107
|
+
} {
|
|
108
|
+
let text = "";
|
|
109
|
+
let thinking = "";
|
|
110
|
+
for (const block of msg.content) {
|
|
111
|
+
if (block.type === "text") {
|
|
112
|
+
text += block.text;
|
|
113
|
+
} else if (isThinkingBlock(block)) {
|
|
114
|
+
thinking += block.thinking || "";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { text, thinking };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Message State Transformation
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Computes the next agent message state during a streaming message_update event.
|
|
126
|
+
* Tracks thinking start/end timestamps so the UI can show a "Thinking..." spinner
|
|
127
|
+
* and collapse/expand the reasoning block after generation finishes.
|
|
128
|
+
*/
|
|
129
|
+
export function buildAgentMessageUpdate(
|
|
130
|
+
prevMsg: UIMessage & { type: "agent" },
|
|
131
|
+
text: string,
|
|
132
|
+
thinking: string,
|
|
133
|
+
assistantEvent?: { type: string }
|
|
134
|
+
): UIMessage {
|
|
135
|
+
const thinkingStarted = thinking.length > 0 && !prevMsg.thinkingStartTime;
|
|
136
|
+
const thinkingJustEnded =
|
|
137
|
+
prevMsg.thinkingStartTime &&
|
|
138
|
+
!prevMsg.thinkingEndTime &&
|
|
139
|
+
(assistantEvent?.type === "thinking_end" ||
|
|
140
|
+
assistantEvent?.type === "text_start" ||
|
|
141
|
+
assistantEvent?.type === "text_delta" ||
|
|
142
|
+
assistantEvent?.type === "toolcall_start" ||
|
|
143
|
+
assistantEvent?.type === "toolcall_delta");
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
...prevMsg,
|
|
147
|
+
content: text,
|
|
148
|
+
thinking: thinking.length > 0 ? thinking : undefined,
|
|
149
|
+
thinkingStartTime: thinkingStarted ? Date.now() : prevMsg.thinkingStartTime,
|
|
150
|
+
thinkingEndTime: thinkingJustEnded ? Date.now() : prevMsg.thinkingEndTime,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function updateAgentMessage(
|
|
155
|
+
messages: UIMessage[],
|
|
156
|
+
msgId: string,
|
|
157
|
+
updater: (msg: UIMessage & { type: "agent" }) => UIMessage
|
|
158
|
+
): UIMessage[] {
|
|
159
|
+
const next = [...messages];
|
|
160
|
+
const idx = next.findIndex((m) => m.id === msgId && m.type === "agent");
|
|
161
|
+
if (idx >= 0) {
|
|
162
|
+
next[idx] = updater(next[idx] as UIMessage & { type: "agent" });
|
|
163
|
+
}
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function removeAgentMessageIfEmpty(
|
|
168
|
+
messages: UIMessage[],
|
|
169
|
+
msgId: string,
|
|
170
|
+
text: string,
|
|
171
|
+
thinking: string
|
|
172
|
+
): UIMessage[] {
|
|
173
|
+
const next = [...messages];
|
|
174
|
+
const idx = next.findIndex((m) => m.id === msgId && m.type === "agent");
|
|
175
|
+
if (idx >= 0) {
|
|
176
|
+
if (text.length === 0 && thinking.length === 0) {
|
|
177
|
+
next.splice(idx, 1);
|
|
178
|
+
} else {
|
|
179
|
+
const prevMsg = next[idx] as UIMessage & { type: "agent" };
|
|
180
|
+
next[idx] = {
|
|
181
|
+
...prevMsg,
|
|
182
|
+
content: text,
|
|
183
|
+
thinking: thinking.length > 0 ? thinking : undefined,
|
|
184
|
+
thinkingEndTime:
|
|
185
|
+
thinking.length > 0 && !prevMsg.thinkingEndTime
|
|
186
|
+
? Date.now()
|
|
187
|
+
: prevMsg.thinkingEndTime,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return next;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// Message Reconstruction
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Rebuilds the UI message list from Pi's session history (`event.messages`),
|
|
200
|
+
* preserving existing UI state (thinkingCollapsed, expanded, etc.) for matched messages.
|
|
201
|
+
* Unmatched messages from the current UI (e.g. tool_call, tool_result) are appended at the end.
|
|
202
|
+
*/
|
|
203
|
+
export function rebuildMessagesFromHistory(
|
|
204
|
+
currentMessages: UIMessage[],
|
|
205
|
+
historyMessages: AgentMessage[],
|
|
206
|
+
pendingMsgId?: string | null
|
|
207
|
+
): UIMessage[] {
|
|
208
|
+
const reordered: UIMessage[] = [];
|
|
209
|
+
const usedIndices = new Set<number>();
|
|
210
|
+
|
|
211
|
+
for (const histMsg of historyMessages) {
|
|
212
|
+
if (isUserMessage(histMsg)) {
|
|
213
|
+
const content = getUserMessageContent(histMsg);
|
|
214
|
+
const idx = currentMessages.findIndex(
|
|
215
|
+
(m, i) => !usedIndices.has(i) && m.type === "user" && m.content === content
|
|
216
|
+
);
|
|
217
|
+
if (idx >= 0) {
|
|
218
|
+
usedIndices.add(idx);
|
|
219
|
+
reordered.push(currentMessages[idx]!);
|
|
220
|
+
} else {
|
|
221
|
+
reordered.push({ id: generateId("user"), type: "user", content });
|
|
222
|
+
}
|
|
223
|
+
} else if (isAssistantMessage(histMsg)) {
|
|
224
|
+
const { text, thinking } = extractTextAndThinking(histMsg);
|
|
225
|
+
|
|
226
|
+
// If the pending streaming agent message ended up empty, skip it
|
|
227
|
+
// so we don't resurrect a removed placeholder.
|
|
228
|
+
const pendingIdx = currentMessages.findIndex(
|
|
229
|
+
(m, i) => !usedIndices.has(i) && m.type === "agent" && m.id === pendingMsgId
|
|
230
|
+
);
|
|
231
|
+
const isEmptyPending = pendingIdx >= 0 && text.length === 0 && thinking.length === 0;
|
|
232
|
+
if (isEmptyPending) {
|
|
233
|
+
usedIndices.add(pendingIdx);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const idx = currentMessages.findIndex(
|
|
238
|
+
(m, i) => !usedIndices.has(i) && m.type === "agent" && m.content === text
|
|
239
|
+
);
|
|
240
|
+
if (idx >= 0) {
|
|
241
|
+
usedIndices.add(idx);
|
|
242
|
+
reordered.push(currentMessages[idx]!);
|
|
243
|
+
|
|
244
|
+
// Pull any trailing tool_call / tool_result messages that immediately
|
|
245
|
+
// followed this agent message in the old UI order so they stay together.
|
|
246
|
+
for (let i = idx + 1; i < currentMessages.length; i++) {
|
|
247
|
+
if (usedIndices.has(i)) break;
|
|
248
|
+
const m = currentMessages[i];
|
|
249
|
+
if (!m) break;
|
|
250
|
+
if (m.type === "tool_call") {
|
|
251
|
+
usedIndices.add(i);
|
|
252
|
+
reordered.push(m);
|
|
253
|
+
} else {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
reordered.push({
|
|
259
|
+
id: generateId("agent"),
|
|
260
|
+
type: "agent",
|
|
261
|
+
content: text,
|
|
262
|
+
thinking: thinking || undefined,
|
|
263
|
+
thinkingCollapsed: true,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
} else if (isCustomPlanMessage(histMsg)) {
|
|
267
|
+
const content = extractCustomPlanContent(histMsg);
|
|
268
|
+
const idx = currentMessages.findIndex(
|
|
269
|
+
(m, i) => !usedIndices.has(i) && m.type === "plan"
|
|
270
|
+
);
|
|
271
|
+
if (idx >= 0) {
|
|
272
|
+
usedIndices.add(idx);
|
|
273
|
+
reordered.push(currentMessages[idx]!);
|
|
274
|
+
} else {
|
|
275
|
+
reordered.push({
|
|
276
|
+
id: generateId("plan"),
|
|
277
|
+
type: "plan",
|
|
278
|
+
content,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Append any unmatched current messages (tool_call, etc.)
|
|
285
|
+
for (let i = 0; i < currentMessages.length; i++) {
|
|
286
|
+
if (!usedIndices.has(i)) {
|
|
287
|
+
reordered.push(currentMessages[i]!);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Deduplicate plan messages: only the latest plan is kept.
|
|
292
|
+
const planIndices: number[] = [];
|
|
293
|
+
for (let i = 0; i < reordered.length; i++) {
|
|
294
|
+
if (reordered[i]!.type === "plan") {
|
|
295
|
+
planIndices.push(i);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (planIndices.length > 1) {
|
|
299
|
+
for (let i = planIndices.length - 2; i >= 0; i--) {
|
|
300
|
+
reordered.splice(planIndices[i]!, 1);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return reordered;
|
|
305
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Type Definitions for Hooks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { UIMessage } from "../../tui/components/chat-panel.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Context passed to event handlers.
|
|
10
|
+
* Contains React setters and refs to avoid stale closures.
|
|
11
|
+
*/
|
|
12
|
+
export interface EventHandlerContext {
|
|
13
|
+
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
|
14
|
+
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>;
|
|
15
|
+
streamingMsgIdRef: React.MutableRefObject<string | null>;
|
|
16
|
+
pendingToolsRef: React.MutableRefObject<Map<string, string>>;
|
|
17
|
+
setSessionTitleState: React.Dispatch<React.SetStateAction<string>>;
|
|
18
|
+
setSessionTitle: (title: string) => void;
|
|
19
|
+
allExpandedRef: React.MutableRefObject<boolean>;
|
|
20
|
+
setSteeringMessages: React.Dispatch<React.SetStateAction<readonly string[]>>;
|
|
21
|
+
setFollowUpMessages: React.Dispatch<React.SetStateAction<readonly string[]>>;
|
|
22
|
+
localSteerQueueRef: React.MutableRefObject<string[]>;
|
|
23
|
+
localFollowUpQueueRef: React.MutableRefObject<string[]>;
|
|
24
|
+
hasToolCallsRef: React.MutableRefObject<boolean>;
|
|
25
|
+
sessionRef: React.MutableRefObject<AgentSession | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Session tree node type derived from AgentSession's sessionManager.
|
|
30
|
+
*/
|
|
31
|
+
export type SessionManagerType = AgentSession["sessionManager"];
|
|
32
|
+
export type SessionTreeNode = ReturnType<SessionManagerType["getTree"]>[number];
|