@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.
Files changed (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
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];