jeo-code 0.1.0 → 0.4.4

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 (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +804 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +562 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -1,6 +1,9 @@
1
1
  import chalk from "chalk";
2
2
  import { BOX_ASCII, BOX_UNICODE, padLineTo, type BoxGlyphs } from "./layout";
3
- import { stripAnsi, visibleWidth } from "./color";
3
+ import { stripAnsi, visibleWidth, animatedGradientText } from "./color";
4
+ import { truncateToWidth } from "./width";
5
+ import { lightHighlightLine } from "./code-view";
6
+ import { type UiCategory } from "./category-index";
4
7
 
5
8
  export interface ForgeSummary {
6
9
  title: string;
@@ -13,10 +16,45 @@ export interface ForgeBoxOptions {
13
16
  maxLines?: number;
14
17
  unicode?: boolean;
15
18
  paint?: (s: string) => string;
19
+ /** Shaded-edge painter (bottom border + right edge). Defaults to a dimmed `paint`
20
+ * when color is on — the lit/shaded two-tone gives the card visible depth. */
21
+ paintShadow?: (s: string) => string;
22
+ index?: number;
23
+ category?: UiCategory;
24
+ color?: boolean;
25
+ /** DNA-flow border animation: a flowing gradient painted over the top/bottom
26
+ * border glyphs (content rows untouched). Stateless — the caller drives
27
+ * `phase` per tick, so nothing is retained between frames. Below TrueColor
28
+ * (`colorLevel < 3`) this degrades to the static `paint`/`paintShadow`. */
29
+ flow?: { palette: readonly string[]; phase: number; colorLevel: number };
30
+ /** Width-1 mark prepended to the border title (e.g. the DNA claw beat glyph). */
31
+ titleMark?: string;
32
+ /** Themed +/- painters for `language: "patch"` cards (edit diffs): applied to
33
+ * the FULL padded row so added/removed lines read as background-tinted
34
+ * stripes — block-level contrast inside the card. */
35
+ diffPaint?: { add: (s: string) => string; del: (s: string) => string };
16
36
  }
17
37
 
18
38
  const SECRET_VALUE_RE = /(api[_-]?key|authorization|bearer|password|secret|token)(\s*[:=]\s*)(["']?)[^"'\s,}]+/gi;
19
39
  const SECRET_JSON_RE = /("(?:api[_-]?key|authorization|password|secret|token)"\s*:\s*")[^"]+(")/gi;
40
+ const EXT_TO_LANG: Record<string, string> = {
41
+ ts: "typescript",
42
+ js: "javascript",
43
+ tsx: "typescript",
44
+ jsx: "javascript",
45
+ json: "json",
46
+ md: "markdown",
47
+ sh: "bash",
48
+ py: "python",
49
+ yaml: "yaml",
50
+ yml: "yaml",
51
+ toml: "toml",
52
+ css: "css",
53
+ html: "html",
54
+ rs: "rust",
55
+ go: "go",
56
+ };
57
+
20
58
 
21
59
  export function redactSecrets(input: string): string {
22
60
  return input
@@ -24,6 +62,19 @@ export function redactSecrets(input: string): string {
24
62
  .replace(SECRET_JSON_RE, "$1<redacted>$2");
25
63
  }
26
64
 
65
+ /**
66
+ * Sentinel for a labeled in-box divider (e.g. the gjc-style `Output` rule between a command
67
+ * echo and its output body). It is `#`-prefixed so app-side helpers that scan summary lines for
68
+ * the command (skipping `#` notes) never surface it; formatForgeBox rewrites it into a real
69
+ * bordered divider row at render time, where the unicode/ASCII glyph set is known.
70
+ */
71
+ const FORGE_DIVIDER_PREFIX = "#\u0000fdiv:";
72
+
73
+ /** Build a labeled-divider sentinel line for inclusion in a ForgeSummary's `lines`. */
74
+ export function forgeDivider(label: string): string {
75
+ return FORGE_DIVIDER_PREFIX + label;
76
+ }
77
+
27
78
  function asRecord(value: unknown): Record<string, unknown> {
28
79
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
29
80
  }
@@ -53,6 +104,96 @@ function previewLines(text: string, maxLines: number, maxChars: number): string[
53
104
  return out.length > 0 ? out : [""];
54
105
  }
55
106
 
107
+ /** jeo-ref file-content preview: a line-number gutter (` 1│ #…`) before each
108
+ * previewed row, closed by `… N more lines` when clipped. ANSI-free so cards
109
+ * stay byte-stable across color modes; `│` degrades to `|` without unicode. */
110
+ function numberedPreviewLines(text: string, maxLines: number, maxChars: number, unicode = true): string[] {
111
+ const clean = redactSecrets(text.replace(/\r\n/g, "\n"));
112
+ const raw = clean.split("\n");
113
+ const gutter = unicode ? "│" : "|";
114
+ const width = String(Math.min(raw.length, maxLines)).length;
115
+ const out: string[] = [];
116
+ let used = 0;
117
+ for (let i = 0; i < raw.length; i++) {
118
+ if (out.length >= maxLines || used >= maxChars) break;
119
+ const remaining = Math.max(0, maxChars - used);
120
+ const line = raw[i]!;
121
+ const next = line.length > remaining ? `${line.slice(0, Math.max(0, remaining - 1))}…` : line;
122
+ out.push(`${String(i + 1).padStart(width)}${gutter} ${next}`);
123
+ used += next.length + 1;
124
+ }
125
+ const hidden = raw.length - out.length;
126
+ if (hidden > 0) out.push(`… ${hidden} more line${hidden === 1 ? "" : "s"}`);
127
+ return out.length > 0 ? out : [""];
128
+ }
129
+
130
+ /**
131
+ * Render an edit tool's `editBlock` as gjc-style diff lines for the forge card:
132
+ * - SEARCH/REPLACE hunks → `- old` / `+ new` rows per hunk (capped), closed by a
133
+ * `~N hunk(s) · +A −R line(s)` summary divider row.
134
+ * - `≔` line-directives → the payload as `+ added` rows with a `+A line(s)` summary.
135
+ * Returns null for formats it does not recognize (caller falls back to a raw preview).
136
+ * Mirrors agent/tools.ts' parse rules WITHOUT importing the agent layer (TUI stays
137
+ * dependency-light); plain +/- prefixes keep the card ANSI-free in every color mode.
138
+ */
139
+ export function editBlockDiffLines(editBlock: string, maxRows = 10): string[] | null {
140
+ const block = editBlock.replace(/\r\n/g, "\n");
141
+ const trimFrame = (s: string): string => {
142
+ let t = s;
143
+ if (t.startsWith("\n")) t = t.slice(1);
144
+ if (t.endsWith("\n")) t = t.slice(0, -1);
145
+ return t;
146
+ };
147
+ const rows: string[] = [];
148
+ let added = 0;
149
+ let removed = 0;
150
+ const push = (prefix: "+" | "-", text: string): void => {
151
+ if (prefix === "+") added++;
152
+ else removed++;
153
+ if (rows.length < maxRows) {
154
+ const redacted = redactSecrets(text);
155
+ const colored = prefix === "+" ? chalk.green(`+ ${redacted}`) : chalk.red(`- ${redacted}`);
156
+ rows.push(colored);
157
+ }
158
+ };
159
+
160
+ if (block.includes("<<<<<<< SEARCH")) {
161
+ const segs = block.split("<<<<<<< SEARCH").slice(1);
162
+ let hunks = 0;
163
+ for (const seg of segs) {
164
+ const eq = seg.indexOf("=======");
165
+ if (eq === -1) return null; // malformed — let the raw preview show it
166
+ const gt = seg.indexOf(">>>>>>>", eq);
167
+ if (gt === -1) return null;
168
+ hunks++;
169
+ const search = trimFrame(seg.slice(0, eq));
170
+ const replace = trimFrame(seg.slice(eq + 7, gt));
171
+ if (search) for (const l of search.split("\n")) push("-", l);
172
+ if (replace) for (const l of replace.split("\n")) push("+", l);
173
+ }
174
+ if (hunks === 0) return null;
175
+ const hidden = added + removed - rows.length;
176
+ if (hidden > 0) rows.push(`… ${hidden} more change line(s)`);
177
+ rows.push(FORGE_DIVIDER_PREFIX + "Summary");
178
+ rows.push(`~${hunks} hunk(s) · +${added} −${removed} line(s)`);
179
+ return rows;
180
+ }
181
+
182
+ if (block.startsWith("≔")) {
183
+ const directive = block.split("\n", 1)[0] ?? "≔";
184
+ const payload = block.includes("\n") ? block.slice(block.indexOf("\n") + 1) : "";
185
+ rows.push(redactSecrets(directive));
186
+ if (payload) for (const l of payload.split("\n")) push("+", l);
187
+ const hidden = added - Math.max(0, rows.length - 1);
188
+ if (hidden > 0) rows.push(`… ${hidden} more line(s)`);
189
+ rows.push(FORGE_DIVIDER_PREFIX + "Summary");
190
+ rows.push(`${directive.slice(0, 24)} · +${added} line(s)`);
191
+ return rows;
192
+ }
193
+
194
+ return null;
195
+ }
196
+
56
197
  function jsonPreview(args: Record<string, unknown>): string[] {
57
198
  try {
58
199
  return previewLines(JSON.stringify(args, null, 2), 6, 500);
@@ -61,24 +202,40 @@ function jsonPreview(args: Record<string, unknown>): string[] {
61
202
  }
62
203
  }
63
204
 
64
- export function summarizeForgeInvocation(tool: string, rawArgs: unknown): ForgeSummary {
205
+ export function summarizeForgeInvocation(tool: string, rawArgs: unknown, opts: { unicode?: boolean } = {}): ForgeSummary {
65
206
  const args = asRecord(rawArgs);
66
- const normalized = tool.toLowerCase();
207
+ const safeTool = tool || "(no tool)";
208
+ const normalized = safeTool.toLowerCase();
67
209
  if (normalized === "bash") {
68
210
  const command = stringArg(args, "command", "cmd") ?? "";
69
211
  const timeout = stringArg(args, "timeoutMs", "timeout");
70
- const lines = [...previewLines(command, 8, 800)];
71
- if (timeout) lines.unshift(`# timeoutMs: ${timeout}`);
72
- return { title: "bash command", language: "bash", lines };
212
+ // gjc-style command echo: prefix the (redacted, capped) command with `$ `.
213
+ const commandLines = previewLines(command, 8, 800);
214
+ const lines = [`$ ${commandLines[0] ?? ""}`, ...commandLines.slice(1)];
215
+ const cwdKey = Object.keys(args).find(k => /^(cwd|workingdir|workingdirectory|subdir|dir)$/i.test(k));
216
+ if (cwdKey !== undefined) {
217
+ const cwdVal = args[cwdKey];
218
+ if (cwdVal !== undefined && cwdVal !== null && cwdVal !== "") {
219
+ lines.push(`# cwd-relative: ${cwdVal}`);
220
+ }
221
+ }
222
+ if (timeout) {
223
+ const ms = Number(timeout);
224
+ const secs = Number.isFinite(ms) ? (ms % 1000 === 0 ? String(ms / 1000) : (ms / 1000).toFixed(1)) : timeout;
225
+ const open = opts.unicode === false ? "[" : "⟦";
226
+ const close = opts.unicode === false ? "]" : "⟧";
227
+ lines.push(`${open}Timeout: ${secs}s${close}`);
228
+ }
229
+ return { title: "Bash", language: "bash", lines };
73
230
  }
74
231
 
75
232
  if (normalized === "read") {
76
233
  const filePath = stringArg(args, "filePath", "path") ?? "<missing path>";
77
234
  const range = stringArg(args, "lineRange", "range");
78
235
  return {
79
- title: `read ${filePath}`,
236
+ title: `Read ${filePath}${range ? `:${range}` : ""}`,
80
237
  language: "path",
81
- lines: [`path: ${filePath}`, range ? `range: ${range}` : "range: full/default preview"],
238
+ lines: [`path: ${filePath}`],
82
239
  };
83
240
  }
84
241
 
@@ -86,43 +243,180 @@ export function summarizeForgeInvocation(tool: string, rawArgs: unknown): ForgeS
86
243
  const filePath = stringArg(args, "filePath", "path") ?? "<missing path>";
87
244
  const content = typeof args.content === "string" ? args.content : "";
88
245
  const lineCount = content.length === 0 ? 0 : content.split("\n").length;
246
+ const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
247
+ const lang = EXT_TO_LANG[ext];
248
+ const langTag = lang ? ` · ${lang}` : "";
89
249
  return {
90
- title: `write ${filePath}`,
91
- language: "text",
92
- lines: [`# ${content.length} bytes · ${lineCount} line(s) -> ${filePath}`, ...previewLines(content, 8, 800)],
250
+ title: `Write ${filePath}`,
251
+ language: lang || "text",
252
+ lines: [
253
+ ...numberedPreviewLines(content, 6, 700, opts.unicode !== false),
254
+ FORGE_DIVIDER_PREFIX + "Summary",
255
+ `wrote ${lineCount} lines, ${content.length} bytes${langTag}`
256
+ ],
93
257
  };
94
258
  }
95
-
96
259
  if (normalized === "edit") {
97
260
  const filePath = stringArg(args, "filePath", "path") ?? "<missing path>";
98
261
  const editBlock = stringArg(args, "editBlock", "edit") ?? "";
262
+ // gjc-style diff view: SEARCH/REPLACE hunks render as -old/+new lines with a
263
+ // hunk/line summary; ≔ directives render their payload as +added lines. Raw
264
+ // fallback keeps unknown formats visible. Plain +/- prefixes (no ANSI) keep
265
+ // the card byte-stable across color modes.
266
+ const diff = editBlockDiffLines(editBlock);
99
267
  return {
100
- title: `edit ${filePath}`,
268
+ title: `Edit : ${filePath}`,
101
269
  language: "patch",
102
- lines: [`# patch -> ${filePath}`, ...previewLines(editBlock, 8, 800)],
270
+ lines: diff ?? [...previewLines(editBlock, 8, 800)],
103
271
  };
104
272
  }
105
-
106
273
  if (normalized === "find") {
107
274
  const pattern = stringArg(args, "globPattern", "pattern") ?? "<missing glob>";
108
- return { title: "find files", language: "glob", lines: [`glob: ${pattern}`] };
275
+ return { title: `Find: ${pattern}`, language: "glob", lines: [`glob: ${pattern}`] };
109
276
  }
110
277
 
111
278
  if (normalized === "search") {
112
279
  const pattern = stringArg(args, "pattern") ?? "<missing pattern>";
113
280
  const glob = stringArg(args, "globPattern", "path") ?? "*";
114
- return { title: "search content", language: "regex", lines: [`pattern: ${pattern}`, `glob: ${glob}`] };
281
+ return { title: `Search: ${pattern}`, language: "regex", lines: [`glob: ${glob}`] };
282
+ }
283
+
284
+ if (normalized === "task") {
285
+ const role = stringArg(args, "role") ?? "executor";
286
+ const task = stringArg(args, "task", "prompt", "assignment") ?? "<missing task>";
287
+ const context = stringArg(args, "context");
288
+ return {
289
+ title: `Task: ${role}`,
290
+ language: "text",
291
+ lines: [...previewLines(task, 4, 500), ...(context ? ["context:", ...previewLines(context, 3, 300)] : [])],
292
+ };
293
+ }
294
+
295
+ if (normalized === "web_search") {
296
+ const query = stringArg(args, "query") ?? "<missing query>";
297
+ const recency = stringArg(args, "recency");
298
+ const lines = [`query: ${query}`];
299
+ if (recency) lines.push(`recency: ${recency}`);
300
+ return {
301
+ title: `Web Search: ${query.length > 60 ? `${query.slice(0, 59)}…` : query}`,
302
+ language: "text",
303
+ lines,
304
+ };
115
305
  }
116
306
 
117
- return { title: `${tool} arguments`, language: "json", lines: jsonPreview(args) };
307
+ return { title: `${safeTool} arguments`, language: "json", lines: jsonPreview(args) };
308
+ }
309
+
310
+ /** Parsed gjc-style Web Search card pieces, reconstructed from the structured
311
+ * `web_search` tool output (see `formatWebSearchOutput` in agent/web-search.ts). */
312
+ export interface WebSearchCard {
313
+ /** `Provider · N sources` header meta for the card title (e.g. `Anthropic · 18 sources`). */
314
+ titleMeta: string;
315
+ lines: string[];
316
+ }
317
+
318
+ /**
319
+ * Build the gjc-style Web Search card body from the tool's structured output:
320
+ * `Query:` line, then `Answer` / `Sources` / `Metadata` divider sections with
321
+ * tree-glyph (`├─`/`└─`) rows and `… N more` truncation — visually mirroring
322
+ * gjc's web_search renderer. Returns null when the output does not carry the
323
+ * structured shape (errors fall back to the generic result card).
324
+ */
325
+ export function webSearchCardLines(output: string, opts: { unicode?: boolean } = {}): WebSearchCard | null {
326
+ if (!output.startsWith("Query: ")) return null;
327
+ const uni = opts.unicode !== false;
328
+ const branch = uni ? "├─" : "|-";
329
+ const last = uni ? "└─" : "`-";
330
+ const cont = uni ? "│ " : "| ";
331
+ const ellipsis = uni ? "…" : "...";
332
+
333
+ // Section split on "## " heads; everything before the first head is the Query line(s).
334
+ const sections = new Map<string, string[]>();
335
+ let current = "__head__";
336
+ sections.set(current, []);
337
+ for (const line of output.split("\n")) {
338
+ const m = /^## (\w+)/.exec(line);
339
+ if (m) {
340
+ current = m[1]!;
341
+ sections.set(current, []);
342
+ continue;
343
+ }
344
+ sections.get(current)!.push(line);
345
+ }
346
+ const answer = (sections.get("Answer") ?? []).filter(l => l.trim());
347
+ const metadata = (sections.get("Metadata") ?? []).filter(l => l.trim());
348
+ if (answer.length === 0 && metadata.length === 0) return null;
349
+
350
+ const lines: string[] = [];
351
+ const queryLine = (sections.get("__head__") ?? []).find(l => l.startsWith("Query: "));
352
+ if (queryLine) lines.push(queryLine);
353
+
354
+ // Answer: tree-glyph preview, capped like gjc's collapsed card.
355
+ const MAX_ANSWER = 3;
356
+ lines.push(forgeDivider("Answer"));
357
+ const answerShown = answer.slice(0, MAX_ANSWER);
358
+ answerShown.forEach((l, i) => {
359
+ const glyph = i === answerShown.length - 1 && answer.length <= MAX_ANSWER ? last : branch;
360
+ lines.push(`${glyph} ${l}`);
361
+ });
362
+ if (answer.length > MAX_ANSWER) lines.push(`${ellipsis} ${answer.length - MAX_ANSWER} more lines`);
363
+
364
+ // Sources: `[n] title (domain) · age` + indented url, capped.
365
+ const sourceRaw = (sections.get("Sources") ?? []).filter(l => l.trim());
366
+ const entries: { title: string; url?: string }[] = [];
367
+ for (const line of sourceRaw) {
368
+ const head = /^\[\d+\] (.*)$/.exec(line);
369
+ if (head) entries.push({ title: head[1]! });
370
+ else if (entries.length > 0 && line.startsWith(" ") && !entries[entries.length - 1]!.url) {
371
+ entries[entries.length - 1]!.url = line.trim();
372
+ }
373
+ }
374
+ const MAX_SOURCES = 6;
375
+ lines.push(forgeDivider("Sources"));
376
+ if (entries.length === 0) lines.push(`${last} No sources returned`);
377
+ const shown = entries.slice(0, MAX_SOURCES);
378
+ shown.forEach((src, i) => {
379
+ const isLast = i === shown.length - 1 && entries.length <= MAX_SOURCES;
380
+ lines.push(`${isLast && !src.url ? last : branch} ${src.title}`);
381
+ if (src.url) lines.push(`${isLast ? last : cont} ${src.url}`);
382
+ });
383
+ if (entries.length > MAX_SOURCES) lines.push(`${last} ${ellipsis} ${entries.length - MAX_SOURCES} more sources`);
384
+
385
+ // Metadata: verbatim key/value lines.
386
+ lines.push(forgeDivider("Metadata"));
387
+ lines.push(...metadata);
388
+
389
+ const sourceCount = entries.length;
390
+ const provider = metadata.find(l => l.startsWith("Provider: "))?.slice("Provider: ".length) ?? "web";
391
+ return { titleMeta: `${provider} · ${sourceCount} source${sourceCount === 1 ? "" : "s"}`, lines };
118
392
  }
119
393
 
120
394
  export function summarizeForgeResult(tool: string, success: boolean, output: string): ForgeSummary {
121
395
  const status = success ? "ok" : "failed";
396
+ const safeTool = tool || "(no tool)";
397
+ const normalized = safeTool.toLowerCase();
398
+ let body = output || "<no output>";
399
+ let exitNote: string | null = null;
400
+ if (normalized === "bash") {
401
+ // gjc-style: the engine prefixes failed bash output with `Exit code N` — surface it
402
+ // as a trailing `Command exited with code N` line below the output body instead.
403
+ const m = body.match(/^Exit code (-?\d+)\n?/);
404
+ if (m) {
405
+ exitNote = `Command exited with code ${m[1]}`;
406
+ body = body.slice(m[0].length) || "<no output>";
407
+ } else if (!success) {
408
+ exitNote = "Command failed";
409
+ }
410
+ }
411
+ const lines = previewLines(body, success ? 5 : 10, success ? 600 : 1200);
412
+ if (normalized === "bash") {
413
+ lines.unshift(forgeDivider("Output"));
414
+ if (exitNote) lines.push("", exitNote);
415
+ }
122
416
  return {
123
- title: `${tool} result ${status}`,
417
+ title: `${safeTool} result ${status}`,
124
418
  language: "output",
125
- lines: previewLines(output || "<no output>", success ? 5 : 10, success ? 600 : 1200),
419
+ lines,
126
420
  };
127
421
  }
128
422
 
@@ -139,28 +433,129 @@ function borderGlyphs(unicode: boolean | undefined): BoxGlyphs {
139
433
  return unicode === false ? BOX_ASCII : BOX_UNICODE;
140
434
  }
141
435
 
436
+ /**
437
+ * Pick as many WHOLE forge boxes as fit `budget` rows. `lines` is the flat render of one or
438
+ * more bordered boxes separated by a single blank line. Boxes are bordered, so a partial box
439
+ * looks broken — this includes only complete boxes, preferring the MOST RECENT (last) ones,
440
+ * and preserves display order. Returns [] when not even one box fits.
441
+ */
442
+ export function fitForgeBoxes(lines: string[], budget: number): string[] {
443
+ if (budget <= 0 || lines.length === 0) return [];
444
+ if (lines.length <= budget) return lines;
445
+ const groups: string[][] = [];
446
+ let cur: string[] = [];
447
+ for (const line of lines) {
448
+ if (line === "") {
449
+ if (cur.length) { groups.push(cur); cur = []; }
450
+ } else {
451
+ cur.push(line);
452
+ }
453
+ }
454
+ if (cur.length) groups.push(cur);
455
+ const kept: string[][] = [];
456
+ let used = 0;
457
+ for (let i = groups.length - 1; i >= 0; i--) {
458
+ const cost = groups[i]!.length + (kept.length ? 1 : 0); // +1 blank separator between boxes
459
+ if (used + cost > budget) break;
460
+ used += cost;
461
+ kept.unshift(groups[i]!);
462
+ }
463
+ const out: string[] = [];
464
+ for (let i = 0; i < kept.length; i++) {
465
+ if (i > 0) out.push("");
466
+ out.push(...kept[i]!);
467
+ }
468
+ return out;
469
+ }
470
+
142
471
  export function formatForgeBox(summary: ForgeSummary, opts: ForgeBoxOptions = {}): string[] {
143
- const width = Math.max(24, Math.min(120, Math.trunc(opts.width ?? 80)));
472
+ const innerWidth = opts.width ?? 80;
473
+ const floor = Math.min(24, innerWidth);
474
+ const width = Math.max(floor, Math.min(120, Math.trunc(innerWidth)));
144
475
  const maxLines = Math.max(1, Math.trunc(opts.maxLines ?? 10));
145
476
  const glyphs = borderGlyphs(opts.unicode);
146
477
  const paint = opts.paint ?? chalk.gray;
478
+ const shadow = opts.paintShadow ?? (opts.color === false ? paint : (s: string) => chalk.dim(paint(s)));
479
+ // DNA-flow border painters: a flowing gradient over the border glyph runs, the
480
+ // bottom offset half a cycle so the helix appears to travel around the card.
481
+ // Pure functions of (text, phase) — no per-frame state is retained — and below
482
+ // TrueColor animatedGradientText returns the text unchanged, so the static
483
+ // paint/shadow path takes over byte-identically.
484
+ const flowOn = !!opts.flow && opts.color !== false;
485
+ const flowTop = (s: string): string => {
486
+ if (!flowOn) return paint(s);
487
+ const g = animatedGradientText(s, opts.flow!.palette, opts.flow!.phase, { colorLevel: opts.flow!.colorLevel });
488
+ return g === s ? paint(s) : g;
489
+ };
490
+ const flowBottom = (s: string): string => {
491
+ if (!flowOn) return shadow(s);
492
+ const g = animatedGradientText(s, opts.flow!.palette, opts.flow!.phase + 0.5, { colorLevel: opts.flow!.colorLevel });
493
+ return g === s ? shadow(s) : g;
494
+ };
147
495
  const inner = Math.max(1, width - 2);
148
- const top = paint(glyphs.tl + glyphs.h.repeat(inner) + glyphs.tr);
149
- const bottom = paint(glyphs.bl + glyphs.h.repeat(inner) + glyphs.br);
150
- const label = summary.language ? `${summary.title} · ${summary.language}` : summary.title;
151
- const title = `${chalk.bold(label)}`;
152
- const rendered: string[] = [top, paint(glyphs.v) + padLineTo(title, inner, "left") + paint(glyphs.v)];
153
- const separator = paint(glyphs.v) + paint(glyphs.h.repeat(inner)) + paint(glyphs.v);
154
- rendered.push(separator);
496
+ // jeo-ref layout: the title rides ON the top border (`╭── Bash ──────╮`)
497
+ // instead of occupying a title row + separator — two chrome rows become one,
498
+ // and the card scans like the reference's labeled panel.
499
+ const mark = opts.titleMark ? `${opts.titleMark} ` : "";
500
+ const label = truncateToWidth(summary.title, Math.max(1, inner - 4 - visibleWidth(mark)));
501
+ const titleText = ` ${mark}${opts.color === false ? label : chalk.bold(label)} `;
502
+ const lead = glyphs.h.repeat(Math.min(2, inner));
503
+ const tail = Math.max(0, inner - visibleWidth(lead) - visibleWidth(titleText));
504
+ const top = flowTop(glyphs.tl + lead) + titleText + flowTop(glyphs.h.repeat(tail) + glyphs.tr);
505
+ const bottom = flowBottom(glyphs.bl + glyphs.h.repeat(inner) + glyphs.br);
506
+ const rendered: string[] = [top];
155
507
 
508
+ // jeo-ref readability: a one-column gutter between the left border and the
509
+ // content (`│ $ cmd …`), so text never touches the frame. Content word-wraps
510
+ // to the guttered width; a labeled divider still counts as one content row.
511
+ const gutterWidth = Math.max(1, inner - 1);
156
512
  const content: string[] = [];
157
513
  for (const line of summary.lines) {
158
- for (const wrapped of wrapPlainLine(line, inner)) content.push(wrapped);
514
+ if (line.startsWith(FORGE_DIVIDER_PREFIX)) { content.push(line); continue; }
515
+ for (const wrapped of wrapPlainLine(line, gutterWidth)) content.push(wrapped);
159
516
  }
517
+ const renderDivider = (rawLabel: string): string => {
518
+ const text = rawLabel ? ` ${rawLabel} ` : "";
519
+ const rest = Math.max(0, inner - visibleWidth(lead) - visibleWidth(text));
520
+ const bar = `${lead}${text}${glyphs.h.repeat(rest)}`;
521
+ return paint(glyphs.v) + paint(padLineTo(bar, inner, "left")) + shadow(glyphs.v);
522
+ };
523
+ // Patch cards: +/- rows get the themed diff stripe painted over the FULL
524
+ // padded row (background tint spans the card width), so added/removed lines
525
+ // separate as blocks instead of relying on a colored sign alone.
526
+ const diffRows = summary.language === "patch" && opts.color !== false ? opts.diffPaint : undefined;
527
+ // Code cards (jeo-ref): conservative single-pass light highlight — comments,
528
+ // string literals, keywords — applied AFTER wrapping so padding math stays
529
+ // visible-width true. color:false stays byte-identical.
530
+ const CODE_LANGS = new Set(["bash", "typescript", "javascript", "python", "json", "yaml", "rust", "go"]);
531
+ const highlightLang = opts.color !== false && summary.language && CODE_LANGS.has(summary.language) ? summary.language : undefined;
532
+ const contentRow = (line: string): string => {
533
+ const lit = highlightLang ? lightHighlightLine(line, highlightLang) : line;
534
+ const padded = padLineTo(` ${lit}`, inner, "left");
535
+ const body = diffRows && line.startsWith("+")
536
+ ? diffRows.add(padded)
537
+ : diffRows && line.startsWith("-")
538
+ ? diffRows.del(padded)
539
+ : padded;
540
+ return paint(glyphs.v) + body + shadow(glyphs.v);
541
+ };
542
+ // gjc-style clip hint: the hidden remainder is reachable via Ctrl+O. Two clip
543
+ // layers can produce an overflow marker — previewLines() at the SUMMARIZE
544
+ // stage ("… N more line(s)") and the box's own maxLines cut below; both must
545
+ // carry the hint, so summarize-stage markers are rewritten here where the
546
+ // unicode capability is known.
547
+ const hint = opts.unicode === false ? "[Ctrl+O for more]" : "⟦Ctrl+O for more⟧";
548
+ const CLIP_MARKER_RE = /^… \d+ more line\(s\)$/;
160
549
  const clipped = content.slice(0, maxLines);
161
- for (const line of clipped) rendered.push(paint(glyphs.v) + padLineTo(line, inner, "left") + paint(glyphs.v));
550
+ for (const line of clipped) {
551
+ if (line.startsWith(FORGE_DIVIDER_PREFIX)) {
552
+ rendered.push(renderDivider(line.slice(FORGE_DIVIDER_PREFIX.length)));
553
+ } else {
554
+ rendered.push(contentRow(CLIP_MARKER_RE.test(line) ? `${line} ${hint}` : line));
555
+ }
556
+ }
162
557
  if (content.length > clipped.length) {
163
- rendered.push(paint(glyphs.v) + padLineTo(`… ${content.length - clipped.length} hidden line(s)`, inner, "left") + paint(glyphs.v));
558
+ rendered.push(contentRow(`… ${content.length - clipped.length} more lines ${hint}`));
164
559
  }
165
560
  rendered.push(bottom);
166
561
  return rendered;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Key-hint bar — a compact, colored row of keybinding/command hints for the TUI
3
+ * (e.g. "^C cancel · Tab complete · /help · /model · /exit"). Keys are
4
+ * highlighted, labels dimmed, the row is clamped to the terminal width, and an
5
+ * ASCII fallback drops fancy separators. Pure functions over a hint list (color
6
+ * via chalk), so they unit-test with an ANSI-stripping helper.
7
+ */
8
+ import chalk from "chalk";
9
+ import { truncate } from "../terminal";
10
+
11
+ export interface KeyHint {
12
+ /** The key/command token, e.g. "^C", "Tab", "/help". */
13
+ key: string;
14
+ /** What it does, e.g. "cancel", "complete". */
15
+ label: string;
16
+ }
17
+
18
+ /** The default interactive hint set. */
19
+ export const DEFAULT_HINTS: readonly KeyHint[] = [
20
+ { key: "^C", label: "cancel" },
21
+ { key: "Tab", label: "complete" },
22
+ { key: "/help", label: "commands" },
23
+ { key: "/model", label: "switch" },
24
+ { key: "^O", label: "history" },
25
+ { key: "/exit", label: "quit" },
26
+ ];
27
+
28
+ export interface HintBarOptions {
29
+ unicode?: boolean;
30
+ color?: boolean;
31
+ /** Clamp the rendered row to this many visible columns. */
32
+ cols?: number;
33
+ /** Indent prefix (default two spaces). */
34
+ indent?: string;
35
+ }
36
+
37
+ /** Render one hint as "<key> <label>" with the key highlighted. */
38
+ export function formatHint(hint: KeyHint, color = true): string {
39
+ const key = color ? chalk.cyan.bold(hint.key) : hint.key;
40
+ const label = color ? chalk.gray(hint.label) : hint.label;
41
+ return `${key} ${label}`;
42
+ }
43
+
44
+ /** Render the hint bar; clamps to `cols` and falls back to ASCII separators. */
45
+ export function formatHintBar(hints: readonly KeyHint[] = DEFAULT_HINTS, opts: HintBarOptions = {}): string {
46
+ if (hints.length === 0) return "";
47
+ const unicode = opts.unicode !== false;
48
+ const color = opts.color !== false;
49
+ const indent = opts.indent ?? " ";
50
+ const sepRaw = unicode ? " · " : " | ";
51
+ const sep = color ? chalk.gray(sepRaw) : sepRaw;
52
+ const row = indent + hints.map(h => formatHint(h, color)).join(sep);
53
+ return opts.cols ? truncate(row, opts.cols) : row;
54
+ }