typeclaw 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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,317 @@
1
+ import { colors } from './theme'
2
+
3
+ const ARGS_PREVIEW_MAX = 200
4
+ const RESULT_PREVIEW_MAX = 400
5
+
6
+ export function formatToolStart(name: string, args: unknown): string {
7
+ const head = `${colors.cyan('●')} ${colors.bold(name)}`
8
+ const preview = previewArgs(name, args)
9
+ return preview === null ? head : `${head} ${colors.dim(preview)}`
10
+ }
11
+
12
+ export function formatToolEnd(name: string, error: boolean, result: unknown, durationMs: number): string {
13
+ const glyph = error ? colors.red('✗') : colors.green('✓')
14
+ const dur = colors.gray(formatDuration(durationMs))
15
+ const head = `${glyph} ${colors.bold(name)} ${dur}`
16
+ const preview = previewResult(name, error, result)
17
+ return preview === null ? head : `${head}\n${preview}`
18
+ }
19
+
20
+ export function formatUserPromptHistory(text: string): string {
21
+ return stripHiddenBlocks(text)
22
+ .split('\n')
23
+ .map((line, idx) => `${colors.dim(idx === 0 ? '>' : '.')} ${line}`)
24
+ .join('\n')
25
+ }
26
+
27
+ function stripHiddenBlocks(text: string): string {
28
+ return text.replace(/<hatching>[\s\S]*?<\/hatching>\s*/g, '').trimStart()
29
+ }
30
+
31
+ export function formatQueuePanel(items: ReadonlyArray<{ text: string }>): string {
32
+ return items.map((item) => `${colors.dim('[QUEUED]')} ${firstLine(item.text)}`).join('\n')
33
+ }
34
+
35
+ function firstLine(text: string): string {
36
+ const idx = text.indexOf('\n')
37
+ if (idx === -1) return text
38
+ const head = text.slice(0, idx)
39
+ const remaining = text.length - idx
40
+ return `${head} ${colors.dim(`(+${remaining} chars)`)}`
41
+ }
42
+
43
+ // Tool-specific argument summaries. Each humanizer collapses the typical
44
+ // `{path, pattern, command, …}` parameter object into a single line that
45
+ // reads naturally next to the tool name. Returning null means "I don't
46
+ // recognize this shape, fall back to the generic compact JSON".
47
+ type ArgRecord = Record<string, unknown>
48
+
49
+ function humanizeArgs(name: string, args: unknown): string | null {
50
+ if (!isObject(args)) return null
51
+ switch (name) {
52
+ case 'read':
53
+ return humanizeReadArgs(args)
54
+ case 'bash':
55
+ return humanizeBashArgs(args)
56
+ case 'edit':
57
+ return humanizeEditArgs(args)
58
+ case 'write':
59
+ return humanizeWriteArgs(args)
60
+ case 'grep':
61
+ return humanizeGrepArgs(args)
62
+ case 'find':
63
+ return humanizeFindArgs(args)
64
+ case 'ls':
65
+ return humanizeLsArgs(args)
66
+ case 'websearch':
67
+ return humanizeWebsearchArgs(args)
68
+ case 'webfetch':
69
+ return humanizeWebfetchArgs(args)
70
+ default:
71
+ return null
72
+ }
73
+ }
74
+
75
+ function humanizeReadArgs(args: ArgRecord): string | null {
76
+ const path = asString(args.path)
77
+ if (path === null) return null
78
+ const offset = asNumber(args.offset)
79
+ const limit = asNumber(args.limit)
80
+ if (offset !== null && limit !== null) return `${path} (lines ${offset}-${offset + limit - 1})`
81
+ if (offset !== null) return `${path} (from line ${offset})`
82
+ if (limit !== null) return `${path} (first ${limit} lines)`
83
+ return path
84
+ }
85
+
86
+ function humanizeBashArgs(args: ArgRecord): string | null {
87
+ const command = asString(args.command)
88
+ if (command === null) return null
89
+ return command.replace(/\s+/g, ' ').trim()
90
+ }
91
+
92
+ function humanizeEditArgs(args: ArgRecord): string | null {
93
+ const path = asString(args.path)
94
+ if (path === null) return null
95
+ const edits = Array.isArray(args.edits) ? args.edits.length : 0
96
+ return edits > 0 ? `${path} (${edits} edit${edits === 1 ? '' : 's'})` : path
97
+ }
98
+
99
+ function humanizeWriteArgs(args: ArgRecord): string | null {
100
+ const path = asString(args.path)
101
+ if (path === null) return null
102
+ const content = asString(args.content)
103
+ if (content === null) return path
104
+ const bytes = Buffer.byteLength(content, 'utf-8')
105
+ return `${path} (${formatBytes(bytes)})`
106
+ }
107
+
108
+ function humanizeGrepArgs(args: ArgRecord): string | null {
109
+ const pattern = asString(args.pattern)
110
+ if (pattern === null) return null
111
+ const where = asString(args.path) ?? asString(args.glob)
112
+ return where ? `"${pattern}" in ${where}` : `"${pattern}"`
113
+ }
114
+
115
+ function humanizeFindArgs(args: ArgRecord): string | null {
116
+ const pattern = asString(args.pattern)
117
+ if (pattern === null) return null
118
+ const where = asString(args.path)
119
+ return where ? `${pattern} in ${where}` : pattern
120
+ }
121
+
122
+ function humanizeLsArgs(args: ArgRecord): string | null {
123
+ return asString(args.path) ?? '.'
124
+ }
125
+
126
+ function humanizeWebsearchArgs(args: ArgRecord): string | null {
127
+ const query = asString(args.query)
128
+ if (query === null) return null
129
+ const source = asString(args.source)
130
+ return source && source !== 'web' ? `"${query}" (${source})` : `"${query}"`
131
+ }
132
+
133
+ function humanizeWebfetchArgs(args: ArgRecord): string | null {
134
+ return asString(args.url)
135
+ }
136
+
137
+ // Tool-specific result enrichments. Most tools already embed a human-readable
138
+ // summary in `content[].text`, so the default path simply extracts that. The
139
+ // exceptions: `edit` benefits from showing the diff, `bash` likes a footer
140
+ // with truncation/full-output info, image reads collapse to `[image]`.
141
+ function humanizeResult(name: string, result: unknown): string | null {
142
+ if (!isObject(result)) return null
143
+ const enriched = enrichResult(name, result)
144
+ if (enriched !== null) return enriched
145
+ return extractContentText(result)
146
+ }
147
+
148
+ function enrichResult(name: string, result: ArgRecord): string | null {
149
+ switch (name) {
150
+ case 'edit':
151
+ return enrichEditResult(result)
152
+ case 'bash':
153
+ return enrichBashResult(result)
154
+ case 'read':
155
+ return enrichReadResult(result)
156
+ case 'websearch':
157
+ return enrichWebsearchResult(result)
158
+ default:
159
+ return null
160
+ }
161
+ }
162
+
163
+ function enrichEditResult(result: ArgRecord): string | null {
164
+ const details = isObject(result.details) ? result.details : null
165
+ const diff = details ? asString(details.diff) : null
166
+ if (diff !== null) return diff
167
+ return null
168
+ }
169
+
170
+ function enrichBashResult(result: ArgRecord): string | null {
171
+ const text = extractContentText(result)
172
+ if (text === null) return null
173
+ const details = isObject(result.details) ? result.details : null
174
+ const fullOutput = details ? asString(details.fullOutputPath) : null
175
+ if (fullOutput === null) return text
176
+ return `${text}\n\nFull output saved to: ${fullOutput}`
177
+ }
178
+
179
+ function enrichReadResult(result: ArgRecord): string | null {
180
+ const content = Array.isArray(result.content) ? result.content : null
181
+ if (content === null) return null
182
+ const hasImage = content.some((part) => isObject(part) && part.type === 'image')
183
+ if (!hasImage) return null
184
+ const mime = content
185
+ .map((part) => (isObject(part) && part.type === 'image' ? asString(part.mimeType) : null))
186
+ .find((m) => m !== null)
187
+ return mime ? `[image: ${mime}]` : '[image]'
188
+ }
189
+
190
+ function enrichWebsearchResult(result: ArgRecord): string | null {
191
+ const details = isObject(result.details) ? result.details : null
192
+ if (details === null) return null
193
+ const results = Array.isArray(details.results) ? details.results : null
194
+ if (results === null || results.length === 0) {
195
+ return extractContentText(result)
196
+ }
197
+ const query = asString(details.query) ?? ''
198
+ const source = asString(details.source) ?? ''
199
+ const header = query ? `${results.length} result${results.length === 1 ? '' : 's'} for "${query}" (${source})` : null
200
+ const lines = results
201
+ .map((entry, i) => formatWebsearchEntry(entry, i + 1))
202
+ .filter((line): line is string => line !== null)
203
+ if (lines.length === 0) return extractContentText(result)
204
+ return header === null ? lines.join('\n') : `${header}\n${lines.join('\n')}`
205
+ }
206
+
207
+ function formatWebsearchEntry(entry: unknown, index: number): string | null {
208
+ if (!isObject(entry)) return null
209
+ const title = asString(entry.title)
210
+ const url = asString(entry.url)
211
+ if (title === null || url === null) return null
212
+ return `${index}. ${title} — ${url}`
213
+ }
214
+
215
+ // AI-SDK style results carry a `content` array of `{type, text|data, …}` parts.
216
+ // We join all `text` parts with blank lines between them and replace any
217
+ // non-text parts with a placeholder so the user sees that something was there.
218
+ function extractContentText(result: ArgRecord): string | null {
219
+ const content = result.content
220
+ if (!Array.isArray(content) || content.length === 0) return null
221
+ const parts: string[] = []
222
+ for (const part of content) {
223
+ if (!isObject(part)) continue
224
+ if (part.type === 'text') {
225
+ const text = asString(part.text)
226
+ if (text !== null) parts.push(text)
227
+ continue
228
+ }
229
+ if (part.type === 'image') {
230
+ const mime = asString(part.mimeType)
231
+ parts.push(mime ? `[image: ${mime}]` : '[image]')
232
+ continue
233
+ }
234
+ parts.push(`[${asString(part.type) ?? 'attachment'}]`)
235
+ }
236
+ if (parts.length === 0) return null
237
+ return parts.join('\n\n')
238
+ }
239
+
240
+ function previewArgs(name: string, args: unknown): string | null {
241
+ if (args === undefined || args === null) return null
242
+ if (typeof args === 'object' && Object.keys(args as object).length === 0) return null
243
+ const humanized = humanizeArgs(name, args)
244
+ const raw = humanized ?? toCompactString(args)
245
+ return truncate(raw, ARGS_PREVIEW_MAX)
246
+ }
247
+
248
+ function previewResult(name: string, error: boolean, result: unknown): string | null {
249
+ if (result === undefined || result === null) return null
250
+ if (typeof result === 'string') return formatPreviewBlock(result, error)
251
+ const humanized = humanizeResult(name, result)
252
+ const raw = humanized ?? toReadableString(result)
253
+ return raw.length === 0 ? null : formatPreviewBlock(raw, error)
254
+ }
255
+
256
+ function formatPreviewBlock(raw: string, error: boolean): string {
257
+ const truncated = truncate(raw, RESULT_PREVIEW_MAX)
258
+ const colorize = error ? colors.red : colors.gray
259
+ return truncated
260
+ .split('\n')
261
+ .map((line) => ` ${colorize(line)}`)
262
+ .join('\n')
263
+ }
264
+
265
+ export function formatDuration(ms: number): string {
266
+ if (ms < 1000) return `${ms}ms`
267
+ const seconds = ms / 1000
268
+ if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 2 : 1)}s`
269
+ const minutes = Math.floor(seconds / 60)
270
+ const remaining = Math.round(seconds - minutes * 60)
271
+ return `${minutes}m${remaining}s`
272
+ }
273
+
274
+ function toCompactString(value: unknown): string {
275
+ if (typeof value === 'string') return value
276
+ try {
277
+ return JSON.stringify(value)
278
+ } catch {
279
+ return String(value)
280
+ }
281
+ }
282
+
283
+ function toReadableString(value: unknown): string {
284
+ if (typeof value === 'string') return value
285
+ try {
286
+ return JSON.stringify(value, null, 2)
287
+ } catch {
288
+ return String(value)
289
+ }
290
+ }
291
+
292
+ function truncate(text: string, max: number): string {
293
+ if (text.length <= max) return text
294
+ const cut = text.slice(0, max)
295
+ const remaining = text.length - max
296
+ return `${cut}… (+${remaining} chars)`
297
+ }
298
+
299
+ function formatBytes(bytes: number): string {
300
+ if (bytes < 1024) return `${bytes}B`
301
+ const kb = bytes / 1024
302
+ if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`
303
+ const mb = kb / 1024
304
+ return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`
305
+ }
306
+
307
+ function isObject(value: unknown): value is Record<string, unknown> {
308
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
309
+ }
310
+
311
+ function asString(value: unknown): string | null {
312
+ return typeof value === 'string' ? value : null
313
+ }
314
+
315
+ function asNumber(value: unknown): number | null {
316
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
317
+ }
@@ -0,0 +1,225 @@
1
+ import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text, TUI } from '@mariozechner/pi-tui'
2
+
3
+ import { createClient as createClientDefault, type Client } from './client'
4
+ import { formatQueuePanel, formatToolEnd, formatToolStart, formatUserPromptHistory } from './format'
5
+ import { colors, editorTheme, markdownTheme } from './theme'
6
+
7
+ export type ClientFactory = (url: string) => Promise<Client>
8
+ export type TerminalFactory = () => Terminal
9
+
10
+ export type TuiOptions = {
11
+ url: string
12
+ initialPrompt?: string
13
+ createClient?: ClientFactory
14
+ createTerminal?: TerminalFactory
15
+ exit?: (code: number) => void
16
+ }
17
+
18
+ export function createTui({
19
+ url,
20
+ initialPrompt,
21
+ createClient = createClientDefault,
22
+ createTerminal = () => new ProcessTerminal(),
23
+ exit = process.exit.bind(process),
24
+ }: TuiOptions) {
25
+ async function run(): Promise<void> {
26
+ const terminal = createTerminal()
27
+ const tui = new TUI(terminal)
28
+
29
+ const status = new Text(colors.dim(`connecting to ${url}...`), 0, 0)
30
+ tui.addChild(status)
31
+ tui.start()
32
+ tui.requestRender()
33
+
34
+ const client = await createClient(url).catch((err) => {
35
+ status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
36
+ tui.requestRender()
37
+ tui.stop()
38
+ exit(1)
39
+ throw err
40
+ })
41
+
42
+ const sessionId = await new Promise<string>((resolve) => {
43
+ let off: (() => void) | undefined
44
+ off = client.onMessage((msg) => {
45
+ if (msg.type === 'connected') {
46
+ off?.()
47
+ resolve(msg.sessionId)
48
+ }
49
+ })
50
+ })
51
+ status.setText(colors.dim(`session: ${sessionId}`))
52
+ tui.requestRender()
53
+
54
+ const editor = new Editor(tui, editorTheme, { paddingX: 0 })
55
+ let replyInFlight = false
56
+ let onReplyDone: (() => void) | null = null
57
+ let currentAssistant: Markdown | null = null
58
+ let currentAssistantText = ''
59
+ let queuePanel: Text | null = null
60
+
61
+ // Pi-tui's Container.addChild appends to the end of the children array.
62
+ // The editor must remain the LAST child at all times so it stays pinned
63
+ // to the bottom of the viewport, with chat history scrolling above it.
64
+ // The queue panel, when present, sits immediately ABOVE the editor (so
65
+ // the layout is [...history, queuePanel?, editor]). Any new history entry
66
+ // is inserted by stripping the queue panel + editor from the tail,
67
+ // appending the entry, then re-appending them in order.
68
+ const appendHistory = (component: Text | Markdown) => {
69
+ if (queuePanel) tui.removeChild(queuePanel)
70
+ tui.removeChild(editor)
71
+ tui.addChild(component)
72
+ if (queuePanel) tui.addChild(queuePanel)
73
+ tui.addChild(editor)
74
+ }
75
+
76
+ const updateQueuePanel = (pending: ReadonlyArray<{ id: string; text: string; ts: number }>) => {
77
+ if (pending.length === 0) {
78
+ if (queuePanel) {
79
+ tui.removeChild(queuePanel)
80
+ queuePanel = null
81
+ tui.requestRender()
82
+ }
83
+ return
84
+ }
85
+ const text = formatQueuePanel(pending)
86
+ if (queuePanel) {
87
+ queuePanel.setText(text)
88
+ } else {
89
+ queuePanel = new Text(text, 0, 0)
90
+ tui.removeChild(editor)
91
+ tui.addChild(queuePanel)
92
+ tui.addChild(editor)
93
+ }
94
+ tui.requestRender()
95
+ }
96
+
97
+ // Reset between text segments so a new Markdown block is created after
98
+ // any non-text event (tool calls). Otherwise text_delta after a tool call
99
+ // would append to the previous Markdown and visually push the tool lines
100
+ // down on every chunk.
101
+ const sealAssistantBlock = () => {
102
+ currentAssistant = null
103
+ currentAssistantText = ''
104
+ }
105
+
106
+ const finishAssistantTurn = () => {
107
+ sealAssistantBlock()
108
+ replyInFlight = false
109
+ onReplyDone?.()
110
+ onReplyDone = null
111
+ }
112
+
113
+ const ensureAssistantBlock = (): Markdown => {
114
+ if (currentAssistant) return currentAssistant
115
+ const md = new Markdown('', 0, 0, markdownTheme)
116
+ currentAssistant = md
117
+ currentAssistantText = ''
118
+ appendHistory(md)
119
+ return md
120
+ }
121
+
122
+ client.onMessage((msg) => {
123
+ switch (msg.type) {
124
+ case 'prompt_started': {
125
+ appendHistory(new Text(formatUserPromptHistory(msg.text), 0, 0))
126
+ tui.requestRender()
127
+ break
128
+ }
129
+ case 'text_delta': {
130
+ const block = ensureAssistantBlock()
131
+ currentAssistantText += msg.delta
132
+ block.setText(currentAssistantText)
133
+ tui.requestRender()
134
+ break
135
+ }
136
+ case 'tool_start': {
137
+ sealAssistantBlock()
138
+ appendHistory(new Text(formatToolStart(msg.name, msg.args), 0, 0))
139
+ tui.requestRender()
140
+ break
141
+ }
142
+ case 'tool_end': {
143
+ sealAssistantBlock()
144
+ appendHistory(new Text(formatToolEnd(msg.name, msg.error, msg.result, msg.durationMs), 0, 0))
145
+ tui.requestRender()
146
+ break
147
+ }
148
+ case 'done': {
149
+ finishAssistantTurn()
150
+ tui.requestRender()
151
+ break
152
+ }
153
+ case 'error': {
154
+ appendHistory(new Text(colors.red(`error: ${msg.message}`), 0, 0))
155
+ finishAssistantTurn()
156
+ tui.requestRender()
157
+ break
158
+ }
159
+ case 'queue_state': {
160
+ updateQueuePanel(msg.pending)
161
+ break
162
+ }
163
+ }
164
+ })
165
+
166
+ const closed = new Promise<void>((resolve) => {
167
+ client.onClose(() => {
168
+ appendHistory(new Text(colors.dim('disconnected'), 0, 0))
169
+ tui.requestRender()
170
+ resolve()
171
+ })
172
+ })
173
+
174
+ function send(text: string): Promise<void> {
175
+ replyInFlight = true
176
+ client.send({ type: 'prompt', text })
177
+ return new Promise<void>((resolve) => {
178
+ onReplyDone = resolve
179
+ })
180
+ }
181
+
182
+ // Esc aborts an in-flight reply. The Editor does not bind Esc, so a
183
+ // top-level input listener can intercept it without fighting the editor.
184
+ tui.addInputListener((data) => {
185
+ if (matchesKey(data, Key.escape) && replyInFlight) {
186
+ client.send({ type: 'abort' })
187
+ return { consume: true }
188
+ }
189
+ return undefined
190
+ })
191
+
192
+ // Ctrl+C exits cleanly. In raw mode the kernel does NOT generate SIGINT,
193
+ // so we must intercept the \x03 byte ourselves. The Editor would otherwise
194
+ // swallow it. tui.stop() restores raw-mode/cursor/echo before we exit.
195
+ tui.addInputListener((data) => {
196
+ if (matchesKey(data, Key.ctrl('c'))) {
197
+ tui.stop()
198
+ client.close()
199
+ exit(0)
200
+ return { consume: true }
201
+ }
202
+ return undefined
203
+ })
204
+
205
+ editor.onSubmit = (text) => {
206
+ if (text.trim().length === 0) return
207
+ editor.setText('')
208
+ editor.addToHistory(text)
209
+ tui.requestRender()
210
+ void send(text)
211
+ }
212
+ tui.addChild(editor)
213
+ tui.setFocus(editor)
214
+ tui.requestRender()
215
+
216
+ if (initialPrompt) {
217
+ await send(initialPrompt)
218
+ }
219
+
220
+ await closed
221
+ tui.stop()
222
+ }
223
+
224
+ return { run }
225
+ }
@@ -0,0 +1,41 @@
1
+ import type { EditorTheme, MarkdownTheme } from '@mariozechner/pi-tui'
2
+
3
+ const wrap = (code: string) => (text: string) => `\x1b[${code}m${text}\x1b[0m`
4
+
5
+ const dim = wrap('2')
6
+ const bold = wrap('1')
7
+ const red = wrap('31')
8
+ const green = wrap('32')
9
+ const yellow = wrap('33')
10
+ const cyan = wrap('36')
11
+ const gray = wrap('90')
12
+
13
+ export const colors = { dim, bold, red, green, yellow, cyan, gray }
14
+
15
+ export const editorTheme: EditorTheme = {
16
+ borderColor: dim,
17
+ selectList: {
18
+ selectedPrefix: cyan,
19
+ selectedText: bold,
20
+ description: dim,
21
+ scrollInfo: dim,
22
+ noMatch: dim,
23
+ },
24
+ }
25
+
26
+ export const markdownTheme: MarkdownTheme = {
27
+ heading: bold,
28
+ link: cyan,
29
+ linkUrl: (text) => dim(`(${text})`),
30
+ code: yellow,
31
+ codeBlock: yellow,
32
+ codeBlockBorder: dim,
33
+ quote: dim,
34
+ quoteBorder: dim,
35
+ hr: dim,
36
+ listBullet: cyan,
37
+ bold,
38
+ italic: (text) => `\x1b[3m${text}\x1b[23m`,
39
+ strikethrough: (text) => `\x1b[9m${text}\x1b[29m`,
40
+ underline: (text) => `\x1b[4m${text}\x1b[24m`,
41
+ }