one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -0,0 +1,533 @@
1
+ /**
2
+ * The multi-question widget behind ask_user_question (pure): state machine and
3
+ * rendering for Claude Code's tabbed question dialog — one tab per question
4
+ * plus Submit, a side-by-side preview pane on single-select questions whose
5
+ * options carry `preview`, per-question notes ("press n to add notes"),
6
+ * multi-select checkboxes, an inline free-text row, and "Chat about this",
7
+ * which hands the whole batch back to the conversation. The thin ctx.ui.custom
8
+ * component in index.ts owns nothing but this state and repaint calls — same
9
+ * split as plan-mode's viewer.
10
+ *
11
+ * Every rendered line must stay within the width given: pi-tui crashes the
12
+ * whole app on an overwide line, so renderWidget runs each line through the
13
+ * ANSI-aware truncateLine as a final guard, cutting plain text before
14
+ * painting wherever it can along the way.
15
+ */
16
+
17
+ import { cutPlainText, padPlainText, truncateLine, visibleWidth, wrapPlainText } from "../lib/tui-render.ts";
18
+ import type { Answer, Question } from "./questions.ts";
19
+
20
+ export type Paint = (color: string, text: string) => string;
21
+ export type Bold = (text: string) => string;
22
+
23
+ /** The theme surface the widget paints with (safeTheme* wrappers from index.ts). */
24
+ export interface WidgetStyle {
25
+ paint: Paint;
26
+ bold: Bold;
27
+ /** Reverse video — the active tab's background chip. */
28
+ inverse: (text: string) => string;
29
+ }
30
+
31
+ const TAB_UNANSWERED = "⊡";
32
+ const TAB_ANSWERED = "⊠";
33
+ const OTHER_PLACEHOLDER = "Type something.";
34
+ const CHAT_LABEL = "Chat about this";
35
+ const NOTES_PLACEHOLDER = "press n to add notes";
36
+ const MAX_PREVIEW_ROWS = 12;
37
+
38
+ // ── state ────────────────────────────────────────────────────────────────
39
+
40
+ export interface QuestionState {
41
+ cursor: number;
42
+ /** Selected option indices (at most one unless multiSelect). */
43
+ selected: number[];
44
+ otherText: string;
45
+ /** Typed free-text is part of the answer. Only ever set with a non-empty otherText. */
46
+ otherChosen: boolean;
47
+ notes: string;
48
+ /** Multi-select "Next" pressed — counts as answered even with nothing ticked. */
49
+ committed: boolean;
50
+ }
51
+
52
+ export interface WidgetState {
53
+ questions: Question[];
54
+ /** 0..N-1 = question tabs, N = the Submit tab. */
55
+ tab: number;
56
+ /** Present while typing into the free-text row or the notes line. */
57
+ editing?: { field: "other" | "notes"; draft: string };
58
+ qs: QuestionState[];
59
+ }
60
+
61
+ export type WidgetResult =
62
+ | { kind: "submit"; answers: Answer[] }
63
+ | { kind: "chat" }
64
+ /** Esc; `answers` holds what the user had picked on other tabs, never submitted. */
65
+ | { kind: "cancel"; answers: Answer[] };
66
+
67
+ export function createWidgetState(questions: Question[]): WidgetState {
68
+ return {
69
+ questions,
70
+ tab: 0,
71
+ qs: questions.map(() => ({
72
+ cursor: 0,
73
+ selected: [],
74
+ otherText: "",
75
+ otherChosen: false,
76
+ notes: "",
77
+ committed: false,
78
+ })),
79
+ };
80
+ }
81
+
82
+ // ── row model ────────────────────────────────────────────────────────────
83
+
84
+ export type Row = { kind: "option"; index: number } | { kind: "other" } | { kind: "next" } | { kind: "chat" };
85
+
86
+ /** Preview pane applies to single-select questions with at least one preview. */
87
+ export function hasPreviews(question: Question): boolean {
88
+ return !question.multiSelect && question.options.some((option) => Boolean(option.preview?.trim()));
89
+ }
90
+
91
+ /**
92
+ * Preview questions get no free-text row (notes are their free-text channel,
93
+ * matching Claude Code); every other question gets one. Multi-select adds an
94
+ * explicit "Next" to commit. "Chat about this" is always last.
95
+ */
96
+ export function rowsFor(question: Question): Row[] {
97
+ const rows: Row[] = question.options.map((_, index) => ({ kind: "option", index }));
98
+ if (!hasPreviews(question)) rows.push({ kind: "other" });
99
+ if (question.multiSelect) rows.push({ kind: "next" });
100
+ rows.push({ kind: "chat" });
101
+ return rows;
102
+ }
103
+
104
+ /**
105
+ * Display numbers per row — the single source for both rendering and digit
106
+ * jumps, so a digit can only ever activate a row whose number is on screen.
107
+ * "Next" gets none (Claude Code leaves it unnumbered), and the preview layout
108
+ * shows "Chat about this" unnumbered too.
109
+ */
110
+ export function rowNumbers(question: Question, rows: Row[]): (number | undefined)[] {
111
+ const unnumbered = (row: Row) => row.kind === "next" || (row.kind === "chat" && hasPreviews(question));
112
+ let count = 0;
113
+ return rows.map((row) => (unnumbered(row) ? undefined : ++count));
114
+ }
115
+
116
+ export function isAnswered(state: QuestionState): boolean {
117
+ return state.committed || state.selected.length > 0 || state.otherChosen;
118
+ }
119
+
120
+ // ── key handling ─────────────────────────────────────────────────────────
121
+
122
+ export type WidgetKey =
123
+ | { kind: "up" | "down" | "prevTab" | "nextTab" | "enter" | "esc" | "backspace" }
124
+ | { kind: "text"; text: string };
125
+
126
+ export function decodeWidgetKey(data: string): WidgetKey | undefined {
127
+ switch (data) {
128
+ case "\x1b[A":
129
+ case "\x1bOA":
130
+ return { kind: "up" };
131
+ case "\x1b[B":
132
+ case "\x1bOB":
133
+ return { kind: "down" };
134
+ case "\x1b[D":
135
+ case "\x1bOD":
136
+ case "\x1b[Z": // shift+tab
137
+ return { kind: "prevTab" };
138
+ case "\x1b[C":
139
+ case "\x1bOC":
140
+ case "\t":
141
+ return { kind: "nextTab" };
142
+ case "\r":
143
+ case "\n":
144
+ return { kind: "enter" };
145
+ case "\x1b":
146
+ case "\x03": // ctrl+c — same intent as escape while the dialog is focused
147
+ return { kind: "esc" };
148
+ case "\x7f":
149
+ case "\b":
150
+ return { kind: "backspace" };
151
+ default: {
152
+ // Unrecognized escape sequences (home/end/F-keys) must not leak their
153
+ // tail characters into a text draft.
154
+ if (data.startsWith("\x1b")) return undefined;
155
+ const text = [...data].filter((char) => char >= " " && char !== "\x7f").join("");
156
+ return text ? { kind: "text", text } : undefined;
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Advance one key. Mutates `state`; returns a result when the dialog is done.
163
+ */
164
+ export function applyWidgetKey(state: WidgetState, key: WidgetKey): WidgetResult | undefined {
165
+ if (state.editing) return applyEditingKey(state, key);
166
+
167
+ const submitTab = state.questions.length;
168
+ switch (key.kind) {
169
+ case "esc":
170
+ return { kind: "cancel", answers: answeredSoFar(state) };
171
+ case "nextTab":
172
+ state.tab = (state.tab + 1) % (submitTab + 1);
173
+ return;
174
+ case "prevTab":
175
+ state.tab = (state.tab + submitTab) % (submitTab + 1);
176
+ return;
177
+ default:
178
+ break;
179
+ }
180
+
181
+ if (state.tab === submitTab) {
182
+ if (key.kind === "enter") {
183
+ const unanswered = state.questions.findIndex((_q, i) => !isAnswered(state.qs[i]));
184
+ if (unanswered >= 0) {
185
+ state.tab = unanswered;
186
+ return;
187
+ }
188
+ return { kind: "submit", answers: collectAnswers(state) };
189
+ }
190
+ return;
191
+ }
192
+
193
+ const question = state.questions[state.tab];
194
+ const questionState = state.qs[state.tab];
195
+ const rows = rowsFor(question);
196
+
197
+ switch (key.kind) {
198
+ case "up":
199
+ questionState.cursor = Math.max(0, questionState.cursor - 1);
200
+ return;
201
+ case "down":
202
+ questionState.cursor = Math.min(rows.length - 1, questionState.cursor + 1);
203
+ return;
204
+ case "enter":
205
+ return activateRow(state, rows[questionState.cursor]);
206
+ case "text": {
207
+ if (key.text === "n" && hasPreviews(question)) {
208
+ state.editing = { field: "notes", draft: questionState.notes };
209
+ return;
210
+ }
211
+ const digit = Number(key.text);
212
+ const target = rowNumbers(question, rows).indexOf(digit);
213
+ if (target >= 0) {
214
+ questionState.cursor = target;
215
+ return activateRow(state, rows[target]);
216
+ }
217
+ return;
218
+ }
219
+ default:
220
+ return;
221
+ }
222
+ }
223
+
224
+ function activateRow(state: WidgetState, row: Row): WidgetResult | undefined {
225
+ const question = state.questions[state.tab];
226
+ const questionState = state.qs[state.tab];
227
+ switch (row.kind) {
228
+ case "option": {
229
+ if (question.multiSelect) {
230
+ const at = questionState.selected.indexOf(row.index);
231
+ if (at >= 0) questionState.selected.splice(at, 1);
232
+ else questionState.selected.push(row.index);
233
+ return;
234
+ }
235
+ questionState.selected = [row.index];
236
+ questionState.otherChosen = false;
237
+ advance(state);
238
+ return;
239
+ }
240
+ case "other":
241
+ state.editing = { field: "other", draft: questionState.otherText };
242
+ return;
243
+ case "next":
244
+ questionState.committed = true;
245
+ advance(state);
246
+ return;
247
+ case "chat":
248
+ return { kind: "chat" };
249
+ }
250
+ }
251
+
252
+ function applyEditingKey(state: WidgetState, key: WidgetKey): WidgetResult | undefined {
253
+ const editing = state.editing;
254
+ if (!editing) return;
255
+ const question = state.questions[state.tab];
256
+ const questionState = state.qs[state.tab];
257
+
258
+ switch (key.kind) {
259
+ case "esc":
260
+ state.editing = undefined;
261
+ return;
262
+ case "backspace":
263
+ editing.draft = editing.draft.slice(0, -1);
264
+ return;
265
+ case "text":
266
+ editing.draft += key.text;
267
+ return;
268
+ case "enter": {
269
+ state.editing = undefined;
270
+ if (editing.field === "notes") {
271
+ questionState.notes = editing.draft.trim();
272
+ return;
273
+ }
274
+ questionState.otherText = editing.draft.trim();
275
+ questionState.otherChosen = questionState.otherText !== "";
276
+ // A typed answer on a single-select question IS the answer.
277
+ if (questionState.otherChosen && !question.multiSelect) {
278
+ questionState.selected = [];
279
+ advance(state);
280
+ }
281
+ return;
282
+ }
283
+ default:
284
+ return; // arrows/tab are inert while typing
285
+ }
286
+ }
287
+
288
+ /** Move to the next unanswered question, or the Submit tab when none remain. */
289
+ function advance(state: WidgetState): void {
290
+ const count = state.questions.length;
291
+ for (let step = 1; step < count; step++) {
292
+ const i = (state.tab + step) % count;
293
+ if (!isAnswered(state.qs[i])) {
294
+ state.tab = i;
295
+ return;
296
+ }
297
+ }
298
+ state.tab = count;
299
+ }
300
+
301
+ // ── answers ──────────────────────────────────────────────────────────────
302
+
303
+ function collectAnswer(question: Question, state: QuestionState): Answer {
304
+ const labels = [...state.selected].sort((a, b) => a - b).map((index) => question.options[index].label);
305
+ if (state.otherChosen) labels.push(state.otherText);
306
+ return {
307
+ question: question.question,
308
+ header: question.header,
309
+ selected: labels,
310
+ freeform: labels.length > 0 && state.selected.length === 0,
311
+ notes: state.notes || undefined,
312
+ };
313
+ }
314
+
315
+ export function collectAnswers(state: WidgetState): Answer[] {
316
+ return state.questions.map((question, i) => collectAnswer(question, state.qs[i]));
317
+ }
318
+
319
+ /** Answers for the questions already answered — what an Esc abandons. */
320
+ function answeredSoFar(state: WidgetState): Answer[] {
321
+ return state.questions.flatMap((question, i) =>
322
+ isAnswered(state.qs[i]) ? [collectAnswer(question, state.qs[i])] : [],
323
+ );
324
+ }
325
+
326
+ // ── rendering ────────────────────────────────────────────────────────────
327
+
328
+ export function renderWidget(state: WidgetState, style: WidgetStyle, width: number): string[] {
329
+ const { paint } = style;
330
+ const out: string[] = [paint("dim", "─".repeat(Math.max(0, width)))];
331
+ out.push(renderTabBar(state, style));
332
+ out.push("");
333
+ if (state.tab === state.questions.length) renderSubmitTab(state, paint, width, out);
334
+ else renderQuestionTab(state, style, width, out);
335
+ out.push("");
336
+ out.push(paint("dim", cutPlainText(footerFor(state), Math.max(1, width - 1))));
337
+ // Final ANSI-aware guard: pi-tui crashes the app on an overwide line, and
338
+ // the painted tab bar can exceed narrow widths.
339
+ return out.map((line) => truncateLine(line, width));
340
+ }
341
+
342
+ /** Active tab is an accent chip (reverse video), like Claude Code's. */
343
+ function renderTabBar(state: WidgetState, style: WidgetStyle): string {
344
+ const { paint, inverse } = style;
345
+ const chip = (text: string, active: boolean) => (active ? inverse(paint("accent", ` ${text} `)) : ` ${text} `);
346
+ const segments = state.questions.map((question, i) => {
347
+ const glyph = isAnswered(state.qs[i]) ? TAB_ANSWERED : TAB_UNANSWERED;
348
+ return chip(`${glyph} ${question.header}`, i === state.tab);
349
+ });
350
+ segments.push(chip("✔ Submit", state.tab === state.questions.length));
351
+ return `${paint("dim", "←")} ${segments.join(" ")} ${paint("dim", "→")}`;
352
+ }
353
+
354
+ export function footerFor(state: WidgetState): string {
355
+ if (state.editing) return "Enter to confirm · Esc to back out";
356
+ if (state.tab === state.questions.length) return "Enter to submit · Tab/shift+Tab to switch questions · Esc to cancel";
357
+ // The preview layout spells out ↑/↓ and the notes key; the stacked layout
358
+ // uses Claude Code's shorter wording.
359
+ if (hasPreviews(state.questions[state.tab]))
360
+ return "Enter to select · ↑/↓ to navigate · n to add notes · Tab/shift+Tab to switch questions · Esc to cancel";
361
+ return "Enter to select · Tab/shift+Tab or arrow keys to navigate · Esc to cancel";
362
+ }
363
+
364
+ function renderSubmitTab(state: WidgetState, paint: Paint, width: number, out: string[]): void {
365
+ const cut = (line: string) => cutPlainText(line, Math.max(1, width - 1));
366
+ for (const [i, question] of state.questions.entries()) {
367
+ const questionState = state.qs[i];
368
+ if (isAnswered(questionState)) {
369
+ const answer = collectAnswer(question, questionState);
370
+ const value = answer.selected.length > 0 ? answer.selected.join(", ") : "(nothing selected)";
371
+ out.push(cut(`· ${question.question} → ${value}`));
372
+ } else {
373
+ out.push(paint("dim", cut(`· ${question.question} → (unanswered)`)));
374
+ }
375
+ }
376
+ const ready = state.questions.every((_q, i) => isAnswered(state.qs[i]));
377
+ out.push("");
378
+ out.push(
379
+ ready
380
+ ? paint("accent", cut("All questions answered — press Enter to submit."))
381
+ : paint("dim", cut("Some questions are unanswered — Enter jumps to the first one.")),
382
+ );
383
+ }
384
+
385
+ function renderQuestionTab(state: WidgetState, style: WidgetStyle, width: number, out: string[]): void {
386
+ const question = state.questions[state.tab];
387
+ for (const line of wrapPlainText(question.question, Math.max(10, width - 1))) out.push(style.bold(line));
388
+ out.push("");
389
+ if (hasPreviews(question)) renderPreviewRows(state, style, width, out);
390
+ else renderStackedRows(state, style, width, out);
391
+ }
392
+
393
+ /** `❯` on the focused row; selection and focus both paint accent. */
394
+ function pointerFor(state: WidgetState, rowIndex: number): string {
395
+ return !state.editing && state.qs[state.tab].cursor === rowIndex ? "❯" : " ";
396
+ }
397
+
398
+ /** The divider + "Chat about this" row both layouts end with (always the last row). */
399
+ function pushChatRow(state: WidgetState, rows: Row[], style: WidgetStyle, width: number, out: string[]): void {
400
+ const { paint, bold } = style;
401
+ const question = state.questions[state.tab];
402
+ const rowIndex = rows.length - 1;
403
+ const number = rowNumbers(question, rows)[rowIndex];
404
+ const label = number === undefined ? CHAT_LABEL : `${number}. ${CHAT_LABEL}`;
405
+ out.push(paint("dim", "─".repeat(Math.max(0, width - 1))));
406
+ const line = bold(cutPlainText(`${pointerFor(state, rowIndex)} ${label}`, Math.max(1, width - 1)));
407
+ const focused = !state.editing && state.qs[state.tab].cursor === rowIndex;
408
+ out.push(focused ? paint("accent", line) : line);
409
+ }
410
+
411
+ function renderStackedRows(state: WidgetState, style: WidgetStyle, width: number, out: string[]): void {
412
+ const { paint, bold } = style;
413
+ const question = state.questions[state.tab];
414
+ const questionState = state.qs[state.tab];
415
+ const rows = rowsFor(question);
416
+ const numbers = rowNumbers(question, rows);
417
+ const cut = (line: string) => cutPlainText(line, Math.max(1, width - 1));
418
+ // Claude Code's look: labels bold, descriptions muted underneath, the whole
419
+ // focused/selected row accent on top of that. Overflow is handled by
420
+ // renderWidget's final truncateLine pass.
421
+ const labelRow = (prefix: string, label: string, highlight: boolean) => {
422
+ const line = `${prefix}${bold(label)}`;
423
+ return highlight ? paint("accent", line) : line;
424
+ };
425
+
426
+ for (const [r, row] of rows.entries()) {
427
+ const focused = !state.editing && questionState.cursor === r;
428
+ const pointer = pointerFor(state, r);
429
+ const number = `${numbers[r]}.`;
430
+ switch (row.kind) {
431
+ case "option": {
432
+ const option = question.options[row.index];
433
+ const check = question.multiSelect ? (questionState.selected.includes(row.index) ? "[✔] " : "[ ] ") : "";
434
+ const selected = questionState.selected.includes(row.index);
435
+ out.push(labelRow(`${pointer} ${number} ${check}`, option.label, focused || selected));
436
+ if (option.description) out.push(paint("muted", cut(` ${" ".repeat(number.length)}${option.description}`)));
437
+ break;
438
+ }
439
+ case "other": {
440
+ const editing = state.editing?.field === "other";
441
+ const check = question.multiSelect ? (questionState.otherChosen ? "[✔] " : "[ ] ") : "";
442
+ if (editing) {
443
+ out.push(paint("accent", cut(`❯ ${number} ${check}${state.editing?.draft ?? ""}▏`)));
444
+ } else if (questionState.otherText) {
445
+ out.push(labelRow(`${pointer} ${number} ${check}`, questionState.otherText, focused || questionState.otherChosen));
446
+ } else {
447
+ out.push(labelRow(`${pointer} ${number} ${check}`, OTHER_PLACEHOLDER, focused));
448
+ }
449
+ break;
450
+ }
451
+ case "next": {
452
+ const line = bold(cut(`${pointer} Next`));
453
+ out.push(focused ? paint("accent", line) : line);
454
+ break;
455
+ }
456
+ case "chat":
457
+ pushChatRow(state, rows, style, width, out);
458
+ break;
459
+ }
460
+ }
461
+ }
462
+
463
+ function renderPreviewRows(state: WidgetState, style: WidgetStyle, width: number, out: string[]): void {
464
+ const { paint } = style;
465
+ const question = state.questions[state.tab];
466
+ const questionState = state.qs[state.tab];
467
+ const rows = rowsFor(question);
468
+ const numbers = rowNumbers(question, rows);
469
+ const cut = (line: string) => cutPlainText(line, Math.max(1, width - 1));
470
+
471
+ // Left column: numbered option labels, padded plain before painting so the
472
+ // ANSI escapes never enter the width math.
473
+ const leftPlain = question.options.map((option, i) => `${pointerFor(state, i)} ${numbers[i]}. ${option.label}`);
474
+ const leftWidth = Math.min(
475
+ Math.max(...leftPlain.map((line) => visibleWidth(line))) + 2,
476
+ Math.max(12, Math.floor(width * 0.45)),
477
+ );
478
+ const leftLines = leftPlain.map((line, i) => {
479
+ const padded = padPlainText(line, leftWidth);
480
+ const focused = !state.editing && questionState.cursor === i;
481
+ return focused || questionState.selected.includes(i) ? paint("accent", padded) : padded;
482
+ });
483
+
484
+ // The preview follows the focused option (falling back to the selection).
485
+ const focusIndex =
486
+ questionState.cursor < question.options.length ? questionState.cursor : (questionState.selected[0] ?? 0);
487
+ const preview = question.options[focusIndex]?.preview?.trim();
488
+ const boxWidth = Math.max(0, width - leftWidth - 2);
489
+ const sideBySide = boxWidth >= 18;
490
+ const box = preview ? previewBox(preview, sideBySide ? boxWidth : Math.max(18, width - 3), paint) : [];
491
+
492
+ if (sideBySide) {
493
+ for (let i = 0; i < Math.max(leftLines.length, box.length); i++) {
494
+ out.push(`${leftLines[i] ?? " ".repeat(leftWidth)}${box[i] ?? ""}`);
495
+ }
496
+ } else {
497
+ out.push(...leftLines.map((line) => line.trimEnd()));
498
+ if (box.length > 0) {
499
+ out.push("");
500
+ out.push(...box.map((line) => ` ${line}`));
501
+ }
502
+ }
503
+
504
+ // Notes sit under the preview, aligned with the box when side by side.
505
+ const notesIndent = " ".repeat(sideBySide ? leftWidth : 2);
506
+ out.push("");
507
+ if (state.editing?.field === "notes") {
508
+ out.push(`${notesIndent}${paint("accent", "Notes: ")}${cut(state.editing.draft)}▏`);
509
+ } else if (questionState.notes) {
510
+ out.push(`${notesIndent}${paint("accent", "Notes: ")}${cut(questionState.notes)}`);
511
+ } else {
512
+ out.push(`${notesIndent}${paint("accent", "Notes: ")}${paint("dim", NOTES_PLACEHOLDER)}`);
513
+ }
514
+
515
+ out.push("");
516
+ pushChatRow(state, rows, style, width, out);
517
+ }
518
+
519
+ function previewBox(preview: string, maxOuter: number, paint: Paint): string[] {
520
+ const inner = Math.max(8, maxOuter - 4);
521
+ const wrapped = wrapPlainText(preview.replace(/\t/g, " "), inner);
522
+ const shown = wrapped.slice(0, MAX_PREVIEW_ROWS);
523
+ const hidden = wrapped.length - shown.length;
524
+ const contentWidth = Math.min(inner, Math.max(8, ...shown.map((line) => visibleWidth(line))));
525
+ const border = paint("dim", "│");
526
+ const body = shown.map((line) => `${border} ${padPlainText(line, contentWidth)} ${border}`);
527
+ if (hidden > 0) body.push(`${border} ${paint("dim", padPlainText(`… +${hidden} more lines`, contentWidth))} ${border}`);
528
+ return [
529
+ paint("dim", `┌${"─".repeat(contentWidth + 2)}┐`),
530
+ ...body,
531
+ paint("dim", `└${"─".repeat(contentWidth + 2)}┘`),
532
+ ];
533
+ }
@@ -37,6 +37,15 @@ export interface SubagentActionsPayload {
37
37
  background?: boolean;
38
38
  /** Agent name, used to label a background review. */
39
39
  agentName?: string;
40
+ /**
41
+ * Background only: the gate answers here exactly once — the rendered flag
42
+ * text when the review raised a concern, undefined when the sequence is
43
+ * fine or auto mode is off — so the emitter can deliver the verdict with
44
+ * the completion report rather than as a trailing notice. Answered
45
+ * synchronously when no review will run, so the emitter never waits for
46
+ * nothing.
47
+ */
48
+ onReview?: (flag: string | undefined) => void;
40
49
  }
41
50
 
42
51
  /** Per-action and total caps: a long-running child must not blow up the prompt. */
@@ -50,29 +59,3 @@ export function recordAction(log: ChildAction[], toolName: string, input: unknow
50
59
  const subject = typeof raw === "string" ? raw : JSON.stringify(raw);
51
60
  log.push({ toolName, subject: subject.slice(0, SUBJECT_LIMIT) });
52
61
  }
53
-
54
- /** Render the log for the classifier, collapsing repeats so 40 reads read as one line. */
55
- export function renderActions(actions: ChildAction[]): string {
56
- const lines: string[] = [];
57
- let previous: string | undefined;
58
- let repeats = 0;
59
-
60
- const flush = () => {
61
- if (previous === undefined) return;
62
- lines.push(repeats > 1 ? `${previous} (×${repeats})` : previous);
63
- };
64
-
65
- for (const action of actions) {
66
- const line = action.subject ? `${action.toolName}: ${action.subject}` : action.toolName;
67
- if (line === previous) {
68
- repeats++;
69
- continue;
70
- }
71
- flush();
72
- previous = line;
73
- repeats = 1;
74
- }
75
- flush();
76
-
77
- return lines.join("\n");
78
- }