pi-cc-extensions 0.8.9 → 0.8.10

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 (42) hide show
  1. package/README.md +3 -2
  2. package/extensions/agent-autocomplete.ts +11 -16
  3. package/extensions/ask-user-question/ask-user-question.ts +29 -7
  4. package/extensions/ask-user-question/config.ts +6 -2
  5. package/extensions/ask-user-question/index.ts +8 -8
  6. package/extensions/ask-user-question/rpc-fallback.ts +16 -4
  7. package/extensions/ask-user-question/state/build-questionnaire.ts +16 -5
  8. package/extensions/ask-user-question/state/key-router.ts +29 -7
  9. package/extensions/ask-user-question/state/questionnaire-session.ts +1 -4
  10. package/extensions/ask-user-question/state/selectors/projections.ts +4 -1
  11. package/extensions/ask-user-question/state/state-reducer.ts +24 -7
  12. package/extensions/ask-user-question/tool/response-envelope.ts +4 -1
  13. package/extensions/ask-user-question/tool/types.ts +5 -1
  14. package/extensions/ask-user-question/tool/validate-questionnaire.ts +10 -2
  15. package/extensions/ask-user-question/view/components/inline-input.ts +15 -2
  16. package/extensions/ask-user-question/view/components/multi-select-view.ts +11 -4
  17. package/extensions/ask-user-question/view/components/option-list-view.ts +14 -5
  18. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +13 -7
  19. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +16 -4
  20. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +5 -1
  21. package/extensions/ask-user-question/view/components/submit-picker.ts +3 -1
  22. package/extensions/ask-user-question/view/components/wrapping-select.ts +20 -4
  23. package/extensions/ask-user-question/view/dialog-builder.ts +27 -7
  24. package/extensions/ask-user-question/view/tab-content-strategy.ts +23 -8
  25. package/extensions/claude-code-style.ts +1658 -1485
  26. package/extensions/compact-style.ts +120 -43
  27. package/extensions/context.ts +502 -394
  28. package/extensions/session-reference-core.ts +8 -3
  29. package/extensions/session-reference.ts +475 -448
  30. package/extensions/tool-diff/ATTRIBUTION.md +28 -0
  31. package/extensions/tool-diff/ansi-utils.ts +113 -0
  32. package/extensions/tool-diff/diff-presentation.ts +70 -0
  33. package/extensions/tool-diff/diff-renderer.ts +2827 -0
  34. package/extensions/tool-diff/index.ts +106 -0
  35. package/extensions/tool-diff/line-width-safety.ts +91 -0
  36. package/extensions/tool-diff/render-utils.ts +5 -0
  37. package/extensions/tool-diff/types.ts +20 -0
  38. package/extensions/tool-diff/write-display-utils.ts +29 -0
  39. package/extensions/tool-diff/write-execution.ts +113 -0
  40. package/extensions/tool-result-sanitize.ts +4 -4
  41. package/package.json +64 -61
  42. package/themes/github-dark-default.json +91 -91
package/README.md CHANGED
@@ -80,11 +80,12 @@
80
80
  ```json
81
81
  {
82
82
  "mode": "compact",
83
- "excludeRenderers": ["edit", "write"]
83
+ "excludeRenderers": ["edit", "write"],
84
+ "fixedEditorFeatures": true
84
85
  }
85
86
  ```
86
87
 
87
- `mode` 可取 `on`、`off` 或 `compact`。旧版 `enabled: true/false` 会自动迁移为 `on/off`。排除名单使用精确工具名;手动修改配置后执行 `/reload`。`Agent` 的专用 renderer 始终保留。
88
+ `mode` 可取 `on`、`off` 或 `compact`。`fixedEditorFeatures` 默认为 `true`,启用 fixed editor 的工具鼠标点击、滚动到底部按钮、消息计数和专用 viewport 映射;设为 `false` 后关闭终端鼠标上报及上述功能,让非 fixed editor 恢复终端原生滚轮滚动(工具仍可通过键盘展开)。`Ctrl+End` 在两种模式下均保留。旧版 `enabled: true/false` 会自动迁移为 `on/off`。排除名单使用精确工具名;手动修改配置后执行 `/reload`。`Agent` 的专用 renderer 始终保留。
88
89
 
89
90
  Compact transcript 行为改编自 [avhagedorn/pi-compact-transcript](https://github.com/avhagedorn/pi-compact-transcript) v0.6.2(MIT,Alan Hagedorn)。
90
91
 
@@ -87,20 +87,15 @@ export function createAgentAutocompleteProvider(
87
87
 
88
88
  const [baseSuggestions, matches] = await Promise.all([
89
89
  current.getSuggestions(lines, cursorLine, cursorCol, options),
90
- Promise.resolve(query.trim()
91
- ? fuzzyFilter(agents, query, (a) => a.name)
92
- : agents),
90
+ Promise.resolve(query.trim() ? fuzzyFilter(agents, query, (a) => a.name) : agents),
93
91
  ]);
94
92
  if (options.signal.aborted) return null;
95
93
 
96
- const agentItems: AutocompleteItem[] = matches
97
- .slice(0, MAX_SUGGESTIONS)
98
- .map((agent) => ({
99
- value: `@${agent.name}`,
100
- label: `[SubAgent] ${agent.displayName}`,
101
- description: agent.description
102
- + (agent.model ? ` · ${agent.model}` : ""),
103
- }));
94
+ const agentItems: AutocompleteItem[] = matches.slice(0, MAX_SUGGESTIONS).map((agent) => ({
95
+ value: `@${agent.name}`,
96
+ label: `[SubAgent] ${agent.displayName}`,
97
+ description: agent.description + (agent.model ? ` · ${agent.model}` : ""),
98
+ }));
104
99
  const hasCompatibleBaseSuggestions = baseSuggestions?.prefix === `@${query}`;
105
100
  const agentValues = new Set(agents.map((agent) => `@${agent.name}`));
106
101
  const baseItems = hasCompatibleBaseSuggestions
@@ -191,11 +186,11 @@ export default function agentAutocompleteExtension(pi: ExtensionAPI): void {
191
186
 
192
187
  return {
193
188
  systemPrompt:
194
- event.systemPrompt
195
- + `\n\nThe user's prompt references these subagent types: ${agentList}. `
196
- + `You MUST use the Agent tool for EACH mentioned subagent to delegate the relevant parts of the request. `
197
- + `Handle different subagents separately — do NOT merge their tasks into a single Agent call. `
198
- + `For example, if the user mentions @coder and @explore, make two separate Agent tool calls, one with subagent_type="coder" and another with subagent_type="explore".`,
189
+ event.systemPrompt +
190
+ `\n\nThe user's prompt references these subagent types: ${agentList}. ` +
191
+ `You MUST use the Agent tool for EACH mentioned subagent to delegate the relevant parts of the request. ` +
192
+ `Handle different subagents separately — do NOT merge their tasks into a single Agent call. ` +
193
+ `For example, if the user mentions @coder and @explore, make two separate Agent tool calls, one with subagent_type="coder" and another with subagent_type="explore".`,
199
194
  };
200
195
  });
201
196
  }
@@ -65,7 +65,11 @@ type SessionModule = typeof import("./state/questionnaire-session.js");
65
65
 
66
66
  type SessionLoad =
67
67
  | { ok: true; module: SessionModule }
68
- | { ok: false; error: Extract<QuestionnaireError, "session_load_failed" | "stale_module_cache">; message: string };
68
+ | {
69
+ ok: false;
70
+ error: Extract<QuestionnaireError, "session_load_failed" | "stale_module_cache">;
71
+ message: string;
72
+ };
69
73
 
70
74
  /**
71
75
  * Lazy-load the ~560ms QuestionnaireSession view/TUI render graph, guarding
@@ -84,7 +88,11 @@ export async function loadQuestionnaireSession(): Promise<SessionLoad> {
84
88
  mod = await import("./state/questionnaire-session.js");
85
89
  } catch (e) {
86
90
  const cause = e instanceof Error ? e.message : String(e);
87
- return { ok: false, error: "session_load_failed", message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})` };
91
+ return {
92
+ ok: false,
93
+ error: "session_load_failed",
94
+ message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
95
+ };
88
96
  }
89
97
  if (typeof mod.QuestionnaireSession !== "function") {
90
98
  const keys = JSON.stringify(Object.keys(mod));
@@ -147,7 +155,8 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
147
155
 
148
156
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
149
157
  const typed = params as unknown as QuestionParams;
150
- if (!ctx.hasUI) return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
158
+ if (!ctx.hasUI)
159
+ return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
151
160
 
152
161
  const validation = validateQuestionnaire(typed);
153
162
  if (validation.ok === false) {
@@ -176,13 +185,19 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
176
185
  }
177
186
  }
178
187
 
179
- const itemsByTab: WrappingSelectItem[][] = typed.questions.map((q) => buildItemsForQuestion(q));
188
+ const itemsByTab: WrappingSelectItem[][] = typed.questions.map((q) =>
189
+ buildItemsForQuestion(q),
190
+ );
180
191
 
181
192
  // Lazy — QuestionnaireSession pulls the ~560ms view/TUI render graph;
182
193
  // load it only when the tool runs, not at extension registration.
183
194
  const sessionLoad = await loadQuestionnaireSession();
184
195
  if (sessionLoad.ok === false) {
185
- return buildToolResult(sessionLoad.message, { answers: [], cancelled: true, error: sessionLoad.error });
196
+ return buildToolResult(sessionLoad.message, {
197
+ answers: [],
198
+ cancelled: true,
199
+ error: sessionLoad.error,
200
+ });
186
201
  }
187
202
  const { QuestionnaireSession } = sessionLoad.module;
188
203
  // Resolve the collapse/expand key spec from config. Default is `ctrl+]`; users
@@ -219,7 +234,11 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
219
234
  if (hasDialogUI(ctx.ui)) {
220
235
  return buildQuestionnaireResponse(await runRpcQuestionnaire(ctx.ui, typed), typed);
221
236
  }
222
- return buildToolResult(ERROR_NO_CUSTOM_UI, { answers: [], cancelled: true, error: "no_custom_ui" });
237
+ return buildToolResult(ERROR_NO_CUSTOM_UI, {
238
+ answers: [],
239
+ cancelled: true,
240
+ error: "no_custom_ui",
241
+ });
223
242
  }
224
243
 
225
244
  return buildQuestionnaireResponse(result, typed);
@@ -237,7 +256,10 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
237
256
  // re-imports and surfaces it through loadQuestionnaireSession's structured
238
257
  // envelope. unref keeps the timer from holding a non-TUI embedder's process
239
258
  // open.
240
- const timer = setTimeout(() => void loadQuestionnaireSession().catch(() => undefined), PREWARM_DELAY_MS);
259
+ const timer = setTimeout(
260
+ () => void loadQuestionnaireSession().catch(() => undefined),
261
+ PREWARM_DELAY_MS,
262
+ );
241
263
  timer.unref?.();
242
264
  }
243
265
 
@@ -58,10 +58,14 @@ function isValidCollapseKeySpec(spec: string): boolean {
58
58
  const modifiers = parts.slice(0, -1);
59
59
  if (modifiers.length !== new Set(modifiers).size) return false;
60
60
  if (!modifiers.every((m) => MODIFIERS.has(m))) return false;
61
- return base.length === 1 ? /[a-z0-9_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]/.test(base) : SPECIAL_KEYS.has(base);
61
+ return base.length === 1
62
+ ? /[a-z0-9_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]/.test(base)
63
+ : SPECIAL_KEYS.has(base);
62
64
  }
63
65
 
64
- export function resolveCollapseKey(config: Pick<AskUserQuestionConfig, "collapseKey">): CollapseKeySpec {
66
+ export function resolveCollapseKey(
67
+ config: Pick<AskUserQuestionConfig, "collapseKey">,
68
+ ): CollapseKeySpec {
65
69
  const raw = config.collapseKey?.trim().toLowerCase();
66
70
  if (raw === undefined || raw === "") return DEFAULT_COLLAPSE_KEY;
67
71
  if (raw === COLLAPSE_KEY_OFF) return COLLAPSE_KEY_OFF;
@@ -3,15 +3,15 @@ import { registerAskUserQuestionTool } from "./ask-user-question.js";
3
3
  import { registerAskUserQuestionReconciler } from "./reconcile.js";
4
4
 
5
5
  export {
6
- ASK_USER_BLOCKED_EVENT,
7
- ASK_USER_PROMPT_EVENT,
8
- type AskUserBlockedEventPayload,
9
- type AskUserPromptEventPayload,
10
- type AskUserPromptOption,
11
- type AskUserPromptQuestion,
6
+ ASK_USER_BLOCKED_EVENT,
7
+ ASK_USER_PROMPT_EVENT,
8
+ type AskUserBlockedEventPayload,
9
+ type AskUserPromptEventPayload,
10
+ type AskUserPromptOption,
11
+ type AskUserPromptQuestion,
12
12
  } from "./events.js";
13
13
 
14
14
  export default function (pi: ExtensionAPI) {
15
- registerAskUserQuestionTool(pi);
16
- registerAskUserQuestionReconciler(pi);
15
+ registerAskUserQuestionTool(pi);
16
+ registerAskUserQuestionReconciler(pi);
17
17
  }
@@ -21,7 +21,12 @@
21
21
  */
22
22
 
23
23
  import { ROW_INTENT_META } from "./state/row-intent.js";
24
- import type { QuestionAnswer, QuestionData, QuestionnaireResult, QuestionParams } from "./tool/types.js";
24
+ import type {
25
+ QuestionAnswer,
26
+ QuestionData,
27
+ QuestionnaireResult,
28
+ QuestionParams,
29
+ } from "./tool/types.js";
25
30
 
26
31
  const MULTI_SELECT_INSTRUCTIONS =
27
32
  'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
@@ -81,12 +86,17 @@ function buildPreviewBlock(question: QuestionData): string {
81
86
  * `QuestionAnswer` is produced per question otherwise, so the envelope is
82
87
  * identical to the TUI path's.
83
88
  */
84
- export async function runRpcQuestionnaire(ui: DialogUI, params: QuestionParams): Promise<QuestionnaireResult> {
89
+ export async function runRpcQuestionnaire(
90
+ ui: DialogUI,
91
+ params: QuestionParams,
92
+ ): Promise<QuestionnaireResult> {
85
93
  const answers: QuestionAnswer[] = [];
86
94
  for (let qi = 0; qi < params.questions.length; qi++) {
87
95
  const q = params.questions[qi];
88
96
  const header = q.header ? `[${q.header}] ` : "";
89
- const answer = q.multiSelect ? await askMultiSelect(ui, q, qi, header) : await askSingleSelect(ui, q, qi, header);
97
+ const answer = q.multiSelect
98
+ ? await askMultiSelect(ui, q, qi, header)
99
+ : await askSingleSelect(ui, q, qi, header);
90
100
  if (answer === undefined) return { answers, cancelled: true };
91
101
  answers.push(answer);
92
102
  }
@@ -143,7 +153,9 @@ async function askMultiSelect(
143
153
  return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
144
154
  }
145
155
  const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
146
- const indices = tokens.map((tok) => (/^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null));
156
+ const indices = tokens.map((tok) =>
157
+ /^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null,
158
+ );
147
159
  if (indices.every((i): i is number => i != null)) {
148
160
  const selected: string[] = [];
149
161
  for (const i of indices) {
@@ -15,7 +15,10 @@ import type { PreviewPaneProps } from "../view/components/preview/preview-pane.j
15
15
  import { PreviewPane } from "../view/components/preview/preview-pane.js";
16
16
  import { SubmitPicker } from "../view/components/submit-picker.js";
17
17
  import { TabBar } from "../view/components/tab-bar.js";
18
- import type { WrappingSelectItem, WrappingSelectTheme } from "../view/components/wrapping-select.js";
18
+ import type {
19
+ WrappingSelectItem,
20
+ WrappingSelectTheme,
21
+ } from "../view/components/wrapping-select.js";
19
22
  import { DialogView } from "../view/dialog-builder.js";
20
23
  import { QuestionnairePropsAdapter } from "../view/props-adapter.js";
21
24
  import type { StatefulView } from "../view/stateful-view.js";
@@ -158,7 +161,9 @@ class QuestionnaireBuilder {
158
161
  optionListView: optionList,
159
162
  previewBlock,
160
163
  });
161
- const multiSelect = question.multiSelect ? new MultiSelectView(this.theme, question) : undefined;
164
+ const multiSelect = question.multiSelect
165
+ ? new MultiSelectView(this.theme, question)
166
+ : undefined;
162
167
  const bodyHeights = this.buildBodyHeights(question, preview, multiSelect);
163
168
  return { optionList, preview, multiSelect, bodyHeights };
164
169
  }
@@ -168,7 +173,9 @@ class QuestionnaireBuilder {
168
173
  preview: PreviewPane,
169
174
  multiSelect: MultiSelectView | undefined,
170
175
  ): (width: number) => TabBodyHeights {
171
- return question.multiSelect ? multiSelectBodyHeights(multiSelect!) : previewBodyHeights(preview);
176
+ return question.multiSelect
177
+ ? multiSelectBodyHeights(multiSelect!)
178
+ : previewBodyHeights(preview);
172
179
  }
173
180
 
174
181
  /**
@@ -204,7 +211,9 @@ class QuestionnaireBuilder {
204
211
  return { current };
205
212
  }
206
213
 
207
- private pickInitialActivePreview(tabs: ReadonlyArray<TabComponents>): StatefulView<PreviewPaneProps> {
214
+ private pickInitialActivePreview(
215
+ tabs: ReadonlyArray<TabComponents>,
216
+ ): StatefulView<PreviewPaneProps> {
208
217
  const idx = selectActivePreviewPaneIndex(this.initialState.currentTab, this.questions.length);
209
218
  return tabs[idx]?.preview ?? tabs[0]!.preview;
210
219
  }
@@ -238,7 +247,9 @@ class QuestionnaireBuilder {
238
247
  ): ReadonlyArray<BoundGlobalBinding> {
239
248
  return [
240
249
  globalBinding({ component: dialog, select: selectDialogProps }),
241
- ...(submitPicker ? [globalBinding({ component: submitPicker, select: selectSubmitPickerProps })] : []),
250
+ ...(submitPicker
251
+ ? [globalBinding({ component: submitPicker, select: selectSubmitPickerProps })]
252
+ : []),
242
253
  ...(tabBar ? [globalBinding({ component: tabBar, select: selectTabBarProps })] : []),
243
254
  ];
244
255
  }
@@ -48,13 +48,19 @@ function totalTabs(runtime: QuestionnaireRuntime): number {
48
48
  return runtime.isMulti ? runtime.questions.length + 1 : 1;
49
49
  }
50
50
 
51
- function computeAutoAdvanceTab(state: QuestionnaireState, runtime: QuestionnaireRuntime): number | undefined {
51
+ function computeAutoAdvanceTab(
52
+ state: QuestionnaireState,
53
+ runtime: QuestionnaireRuntime,
54
+ ): number | undefined {
52
55
  if (!runtime.isMulti) return undefined;
53
56
  if (state.currentTab < runtime.questions.length - 1) return state.currentTab + 1;
54
57
  return runtime.questions.length;
55
58
  }
56
59
 
57
- function buildSingleSelectAnswer(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionAnswer | null {
60
+ function buildSingleSelectAnswer(
61
+ state: QuestionnaireState,
62
+ runtime: QuestionnaireRuntime,
63
+ ): QuestionAnswer | null {
58
64
  const q = runtime.questions[state.currentTab];
59
65
  if (!q) return null;
60
66
 
@@ -114,16 +120,32 @@ function tabSwitchAction(
114
120
  }
115
121
 
116
122
  // DOWN at the last item wraps to the first (cycle through [option0, …, optionLast]).
117
- function nextNavOnDown(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
118
- return { kind: "nav", nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)) };
123
+ function nextNavOnDown(
124
+ state: QuestionnaireState,
125
+ runtime: QuestionnaireRuntime,
126
+ ): QuestionnaireAction {
127
+ return {
128
+ kind: "nav",
129
+ nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)),
130
+ };
119
131
  }
120
132
 
121
133
  // UP at the first item wraps to the last (symmetric with nextNavOnDown).
122
- function prevNavOnUp(state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
123
- return { kind: "nav", nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)) };
134
+ function prevNavOnUp(
135
+ state: QuestionnaireState,
136
+ runtime: QuestionnaireRuntime,
137
+ ): QuestionnaireAction {
138
+ return {
139
+ kind: "nav",
140
+ nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)),
141
+ };
124
142
  }
125
143
 
126
- export function routeKey(data: string, state: QuestionnaireState, runtime: QuestionnaireRuntime): QuestionnaireAction {
144
+ export function routeKey(
145
+ data: string,
146
+ state: QuestionnaireState,
147
+ runtime: QuestionnaireRuntime,
148
+ ): QuestionnaireAction {
127
149
  const kb = runtime.keybindings;
128
150
 
129
151
  // Collapse/expand is intercepted before inner controls. The questionnaire stays
@@ -91,9 +91,7 @@ export class QuestionnaireSession {
91
91
  const theme = config.theme;
92
92
  // Collapsed render keeps one focused hint row in the temporary editor slot,
93
93
  // so the transcript regains space while Ctrl+] can still expand the questionnaire.
94
- const collapsedRender = (_width: number): string[] => [
95
- theme.fg("dim", ` ${COLLAPSED_HINT} `),
96
- ];
94
+ const collapsedRender = (_width: number): string[] => [theme.fg("dim", ` ${COLLAPSED_HINT} `)];
97
95
 
98
96
  this.component = {
99
97
  render: (width) => (this.state.collapsed ? collapsedRender(width) : built.render(width)),
@@ -192,5 +190,4 @@ export class QuestionnaireSession {
192
190
  const arr = this.itemsByTab[this.state.currentTab] ?? [];
193
191
  return this.state.optionIndex < arr.length ? arr[this.state.optionIndex] : undefined;
194
192
  }
195
-
196
193
  }
@@ -1,4 +1,7 @@
1
- import { MULTI_SUBMIT_LABEL, type MultiSelectViewProps } from "../../view/components/multi-select-view.js";
1
+ import {
2
+ MULTI_SUBMIT_LABEL,
3
+ type MultiSelectViewProps,
4
+ } from "../../view/components/multi-select-view.js";
2
5
  import type { OptionListViewProps } from "../../view/components/option-list-view.js";
3
6
  import type { PreviewPaneProps } from "../../view/components/preview/preview-pane.js";
4
7
  import type { SubmitPickerProps } from "../../view/components/submit-picker.js";
@@ -29,7 +29,10 @@ export interface ApplyResult {
29
29
  effects: readonly Effect[];
30
30
  }
31
31
 
32
- function orderedAnswers(state: QuestionnaireState, questions: readonly QuestionData[]): QuestionAnswer[] {
32
+ function orderedAnswers(
33
+ state: QuestionnaireState,
34
+ questions: readonly QuestionData[],
35
+ ): QuestionAnswer[] {
33
36
  const out: QuestionAnswer[] = [];
34
37
  for (let i = 0; i < questions.length; i++) {
35
38
  const a = state.answers.get(i);
@@ -54,7 +57,10 @@ function syncMultiSelectFromAnswers(
54
57
  return indices;
55
58
  }
56
59
 
57
- function persistMultiSelectAnswer(state: QuestionnaireState, ctx: ApplyContext): ReadonlyMap<number, QuestionAnswer> {
60
+ function persistMultiSelectAnswer(
61
+ state: QuestionnaireState,
62
+ ctx: ApplyContext,
63
+ ): ReadonlyMap<number, QuestionAnswer> {
58
64
  const q = ctx.questions[state.currentTab];
59
65
  if (!q?.multiSelect) return state.answers;
60
66
  const selected: string[] = [];
@@ -87,7 +93,11 @@ function notesValueFor(state: QuestionnaireState, tab: number): string {
87
93
  return state.notesByTab.get(tab) ?? state.answers.get(tab)?.notes ?? "";
88
94
  }
89
95
 
90
- function switchTabResult(state: QuestionnaireState, nextTab: number, ctx: ApplyContext): ApplyResult {
96
+ function switchTabResult(
97
+ state: QuestionnaireState,
98
+ nextTab: number,
99
+ ctx: ApplyContext,
100
+ ): ApplyResult {
91
101
  const notesValue = notesValueFor(state, nextTab);
92
102
  const transitioned: QuestionnaireState = {
93
103
  ...state,
@@ -138,7 +148,8 @@ const navHandler: Handler<"nav"> = (state, action, ctx) => {
138
148
  return { state: next, effects: [] };
139
149
  };
140
150
 
141
- const tabSwitchHandler: Handler<"tab_switch"> = (state, action, ctx) => switchTabResult(state, action.nextTab, ctx);
151
+ const tabSwitchHandler: Handler<"tab_switch"> = (state, action, ctx) =>
152
+ switchTabResult(state, action.nextTab, ctx);
142
153
 
143
154
  const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
144
155
  let answer = action.answer;
@@ -158,7 +169,8 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
158
169
  // Custom free-text on a multi-select tab is mutually exclusive with checkbox selections:
159
170
  // clear the checked set immediately so [✔] glyphs vanish on Enter. (A custom answer
160
171
  // carries no `selected` array, so syncMultiSelectFromAnswers keeps it empty on tab-back.)
161
- const isCustomMulti = answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true;
172
+ const isCustomMulti =
173
+ answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true;
162
174
  const next: QuestionnaireState = {
163
175
  ...state,
164
176
  answers,
@@ -195,7 +207,8 @@ const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => {
195
207
  answers,
196
208
  multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab),
197
209
  };
198
- if (action.autoAdvanceTab !== undefined) return switchTabResult(synced, action.autoAdvanceTab, ctx);
210
+ if (action.autoAdvanceTab !== undefined)
211
+ return switchTabResult(synced, action.autoAdvanceTab, ctx);
199
212
  return doneFor(synced, ctx, false);
200
213
  };
201
214
 
@@ -276,7 +289,11 @@ const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
276
289
  * Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable.
277
290
  * `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path.
278
291
  */
279
- export function reduce(state: QuestionnaireState, action: QuestionnaireAction, ctx: ApplyContext): ApplyResult {
292
+ export function reduce(
293
+ state: QuestionnaireState,
294
+ action: QuestionnaireAction,
295
+ ctx: ApplyContext,
296
+ ): ApplyResult {
280
297
  const handler = HANDLERS[action.kind] as Handler<typeof action.kind>;
281
298
  return handler(state, action as never, ctx);
282
299
  }
@@ -10,7 +10,10 @@ export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in
10
10
  * Pure of `(result, params)`; cancelled and "no segments" both fall to `DECLINE_MESSAGE`
11
11
  * so the model sees a single canonical "didn't answer" signal regardless of why.
12
12
  */
13
- export function buildQuestionnaireResponse(result: QuestionnaireResult | null | undefined, params: QuestionParams) {
13
+ export function buildQuestionnaireResponse(
14
+ result: QuestionnaireResult | null | undefined,
15
+ params: QuestionParams,
16
+ ) {
14
17
  if (!result || result.cancelled) {
15
18
  return buildToolResult(DECLINE_MESSAGE, {
16
19
  answers: result?.answers ?? [],
@@ -34,7 +34,11 @@ export type SentinelLabel = (typeof SENTINEL_LABELS)[SentinelKind];
34
34
  * `["Other", other, next]` literal so consumers using
35
35
  * `RESERVED_LABELS[i]` indexing or `Set` membership see no behavior change.
36
36
  */
37
- export const RESERVED_LABELS = ["Other", ROW_INTENT_META.other.label, ROW_INTENT_META.next.label] as const;
37
+ export const RESERVED_LABELS = [
38
+ "Other",
39
+ ROW_INTENT_META.other.label,
40
+ ROW_INTENT_META.next.label,
41
+ ] as const;
38
42
  export type ReservedLabel = (typeof RESERVED_LABELS)[number];
39
43
 
40
44
  export const OptionSchema = Type.Object({
@@ -1,4 +1,10 @@
1
- import { MAX_QUESTIONS, MIN_OPTIONS, type QuestionnaireError, type QuestionParams, RESERVED_LABELS } from "./types.js";
1
+ import {
2
+ MAX_QUESTIONS,
3
+ MIN_OPTIONS,
4
+ type QuestionnaireError,
5
+ type QuestionParams,
6
+ RESERVED_LABELS,
7
+ } from "./types.js";
2
8
 
3
9
  export const ERROR_NO_QUESTIONS = "Error: At least one question is required";
4
10
  export const ERROR_TOO_MANY_QUESTIONS = `Error: At most ${MAX_QUESTIONS} questions are allowed per invocation`;
@@ -9,7 +15,9 @@ export const ERROR_DUPLICATE_OPTION_LABEL = "Error: Option labels must be unique
9
15
 
10
16
  const RESERVED_LABEL_SET: ReadonlySet<string> = new Set(RESERVED_LABELS);
11
17
 
12
- export type ValidationResult = { ok: true } | { ok: false; error: QuestionnaireError; message: string };
18
+ export type ValidationResult =
19
+ | { ok: true }
20
+ | { ok: false; error: QuestionnaireError; message: string };
13
21
 
14
22
  /**
15
23
  * Pure runtime validator for `QuestionParams`. Covers every guard except
@@ -1,4 +1,9 @@
1
- import { CURSOR_MARKER, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
1
+ import {
2
+ CURSOR_MARKER,
3
+ truncateToWidth,
4
+ visibleWidth,
5
+ wrapTextWithAnsi,
6
+ } from "@earendil-works/pi-tui";
2
7
 
3
8
  // Grapheme-aware extraction at the cursor: pi-tui's Input advances `cursor` by
4
9
  // grapheme-cluster code-unit length, so the cursor can land between code units of
@@ -68,7 +73,15 @@ function buildCursorRaw(buffer: string, offset: number): string {
68
73
  * pi-tui Input.render, ink-text-input, terkelg/prompts, ratatui's user-input example.
69
74
  */
70
75
  export function renderInlineInputRow(opts: RenderInlineInputOptions): string[] {
71
- const { buffer, cursorOffset, rowPrefix, continuationPrefix, contentWidth, selectedText, multiline } = opts;
76
+ const {
77
+ buffer,
78
+ cursorOffset,
79
+ rowPrefix,
80
+ continuationPrefix,
81
+ contentWidth,
82
+ selectedText,
83
+ multiline,
84
+ } = opts;
72
85
  const offset = resolveCursorOffset(buffer, cursorOffset);
73
86
 
74
87
  if (multiline) {
@@ -80,7 +80,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
80
80
  // Checked uses the same `accent` hue as the active-row label so checked rows read
81
81
  // as "selected" rather than "success" — matches the visual rhythm of the rest of
82
82
  // the dialog (active pointer, label, picker rows are all accent).
83
- const box = row.checked ? this.theme.fg("accent", CHECKED) : this.theme.fg("muted", UNCHECKED);
83
+ const box = row.checked
84
+ ? this.theme.fg("accent", CHECKED)
85
+ : this.theme.fg("muted", UNCHECKED);
84
86
  const label = truncateToWidth(opt.label, contentWidth, "…");
85
87
  const styledLabel = row.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
86
88
  const num = String(i + 1).padStart(numberWidth, " ");
@@ -125,7 +127,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
125
127
  ),
126
128
  );
127
129
 
128
- const nextPointer = this.props.nextActive ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
130
+ const nextPointer = this.props.nextActive
131
+ ? this.theme.fg("accent", ACTIVE_POINTER)
132
+ : INACTIVE_POINTER;
129
133
  const nextLabel = this.props.nextActive
130
134
  ? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
131
135
  : this.props.nextLabel;
@@ -145,7 +149,8 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
145
149
  const opt = this.question.options[i];
146
150
  const r = this.props.rows[i];
147
151
  if (!opt || !r) continue;
148
- const itemHeight = 1 + (opt.description ? wrapTextWithAnsi(opt.description, contentWidth).length : 0);
152
+ const itemHeight =
153
+ 1 + (opt.description ? wrapTextWithAnsi(opt.description, contentWidth).length : 0);
149
154
  if (r.active) {
150
155
  return [row, row + itemHeight];
151
156
  }
@@ -185,7 +190,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
185
190
  // out of the canonical computation.
186
191
  const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
187
192
  return (
188
- visibleWidth(INACTIVE_POINTER) + numberWidth + visibleWidth(`${NUMBER_SEPARATOR}${UNCHECKED}${BOX_LABEL_GAP}`)
193
+ visibleWidth(INACTIVE_POINTER) +
194
+ numberWidth +
195
+ visibleWidth(`${NUMBER_SEPARATOR}${UNCHECKED}${BOX_LABEL_GAP}`)
189
196
  );
190
197
  }
191
198
  }
@@ -1,5 +1,9 @@
1
1
  import type { StatefulView } from "../stateful-view.js";
2
- import { WrappingSelect, type WrappingSelectItem, type WrappingSelectTheme } from "./wrapping-select.js";
2
+ import {
3
+ WrappingSelect,
4
+ type WrappingSelectItem,
5
+ type WrappingSelectTheme,
6
+ } from "./wrapping-select.js";
3
7
 
4
8
  /**
5
9
  * Maximum number of option rows visible in the WrappingSelect window. Lifted here from
@@ -38,10 +42,15 @@ export class OptionListView implements StatefulView<OptionListViewProps> {
38
42
  private readonly select: WrappingSelect;
39
43
 
40
44
  constructor(config: OptionListViewConfig) {
41
- this.select = new WrappingSelect(config.items, Math.min(config.items.length, MAX_VISIBLE_OPTIONS), config.theme, {
42
- numberStartOffset: 0,
43
- totalItemsForNumbering: config.items.length,
44
- });
45
+ this.select = new WrappingSelect(
46
+ config.items,
47
+ Math.min(config.items.length, MAX_VISIBLE_OPTIONS),
48
+ config.theme,
49
+ {
50
+ numberStartOffset: 0,
51
+ totalItemsForNumbering: config.items.length,
52
+ },
53
+ );
45
54
  }
46
55
 
47
56
  setProps(props: OptionListViewProps): void {
@@ -65,9 +65,13 @@ export class PreviewBlockRenderer {
65
65
  * — the affordance overhead is constant, not gated by `focused`/`notesVisible`.
66
66
  */
67
67
  blockHeight(width: number, optionIndex: number, mode: PreviewLayoutMode): number {
68
- const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
68
+ const cap =
69
+ mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
69
70
  const contentBudget = Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
70
- const innerWidth = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
71
+ const innerWidth = Math.max(
72
+ 1,
73
+ width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL,
74
+ );
71
75
  const rawRows = this.cache.bodyFor(optionIndex, innerWidth).length;
72
76
  const contentRows = Math.min(rawRows, contentBudget);
73
77
  return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
@@ -86,9 +90,13 @@ export class PreviewBlockRenderer {
86
90
  focused: boolean,
87
91
  notesVisible: boolean,
88
92
  ): string[] {
89
- const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
93
+ const cap =
94
+ mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
90
95
  const contentBudget = Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
91
- const maxInnerWidth = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
96
+ const maxInnerWidth = Math.max(
97
+ 1,
98
+ width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL,
99
+ );
92
100
 
93
101
  const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
94
102
  const truncated = raw.length > contentBudget;
@@ -100,9 +108,7 @@ export class PreviewBlockRenderer {
100
108
  const boxedLines = renderBorderedBox(contentLines, boxWidth, colorFn, hidden);
101
109
 
102
110
  const showAffordance = focused && !notesVisible && this.cache.has(optionIndex);
103
- const affordance = showAffordance
104
- ? this.theme.fg("muted", NOTES_AFFORDANCE_TEXT)
105
- : "";
111
+ const affordance = showAffordance ? this.theme.fg("muted", NOTES_AFFORDANCE_TEXT) : "";
106
112
  return [...boxedLines, "", affordance];
107
113
  }
108
114
  }