pi-cc-extensions 0.8.9 → 0.8.11
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.
- package/README.md +3 -2
- package/extensions/agent-autocomplete.ts +11 -16
- package/extensions/ask-user-question/ask-user-question.ts +29 -7
- package/extensions/ask-user-question/config.ts +6 -2
- package/extensions/ask-user-question/index.ts +8 -8
- package/extensions/ask-user-question/rpc-fallback.ts +16 -4
- package/extensions/ask-user-question/state/build-questionnaire.ts +16 -5
- package/extensions/ask-user-question/state/key-router.ts +29 -7
- package/extensions/ask-user-question/state/questionnaire-session.ts +1 -4
- package/extensions/ask-user-question/state/selectors/projections.ts +4 -1
- package/extensions/ask-user-question/state/state-reducer.ts +24 -7
- package/extensions/ask-user-question/tool/response-envelope.ts +4 -1
- package/extensions/ask-user-question/tool/types.ts +5 -1
- package/extensions/ask-user-question/tool/validate-questionnaire.ts +10 -2
- package/extensions/ask-user-question/view/components/inline-input.ts +15 -2
- package/extensions/ask-user-question/view/components/multi-select-view.ts +11 -4
- package/extensions/ask-user-question/view/components/option-list-view.ts +14 -5
- package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +13 -7
- package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +16 -4
- package/extensions/ask-user-question/view/components/preview/preview-pane.ts +5 -1
- package/extensions/ask-user-question/view/components/submit-picker.ts +3 -1
- package/extensions/ask-user-question/view/components/wrapping-select.ts +20 -4
- package/extensions/ask-user-question/view/dialog-builder.ts +27 -7
- package/extensions/ask-user-question/view/tab-content-strategy.ts +23 -8
- package/extensions/claude-code-style.ts +1722 -1485
- package/extensions/compact-style.ts +120 -43
- package/extensions/context.ts +502 -394
- package/extensions/session-reference-core.ts +8 -3
- package/extensions/session-reference.ts +475 -448
- package/extensions/tool-diff/ATTRIBUTION.md +28 -0
- package/extensions/tool-diff/ansi-utils.ts +113 -0
- package/extensions/tool-diff/diff-presentation.ts +70 -0
- package/extensions/tool-diff/diff-renderer.ts +2827 -0
- package/extensions/tool-diff/index.ts +106 -0
- package/extensions/tool-diff/line-width-safety.ts +91 -0
- package/extensions/tool-diff/render-utils.ts +5 -0
- package/extensions/tool-diff/types.ts +20 -0
- package/extensions/tool-diff/write-display-utils.ts +29 -0
- package/extensions/tool-diff/write-execution.ts +113 -0
- package/extensions/tool-result-sanitize.ts +4 -4
- package/package.json +64 -61
- 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
|
|
88
|
+
`mode` 可取 `on`、`off` 或 `compact`。`fixedEditorFeatures` 默认为 `true`,启用 fixed editor 的每刻度 5 行滚动、工具鼠标点击、收起位置补偿、滚动到底部按钮、消息计数和专用 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
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
| {
|
|
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 {
|
|
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)
|
|
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) =>
|
|
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, {
|
|
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, {
|
|
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(
|
|
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
|
|
61
|
+
return base.length === 1
|
|
62
|
+
? /[a-z0-9_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]/.test(base)
|
|
63
|
+
: SPECIAL_KEYS.has(base);
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
export function resolveCollapseKey(
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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) =>
|
|
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 {
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
118
|
-
|
|
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(
|
|
123
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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) =>
|
|
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 =
|
|
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)
|
|
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(
|
|
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(
|
|
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 = [
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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) +
|
|
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 {
|
|
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(
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
}
|