@ilya-lesikov/pi-pi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/3p/pi-ask-user/index.ts +1835 -0
- package/3p/pi-ask-user/package.json +50 -0
- package/3p/pi-ask-user/single-select-layout.ts +203 -0
- package/3p/pi-lsp/extensions/lsp/client.ts +542 -0
- package/3p/pi-lsp/extensions/lsp/config.ts +135 -0
- package/3p/pi-lsp/extensions/lsp/effects/command.ts +42 -0
- package/3p/pi-lsp/extensions/lsp/effects/filesystem.ts +50 -0
- package/3p/pi-lsp/extensions/lsp/effects/runtime.ts +21 -0
- package/3p/pi-lsp/extensions/lsp/errors.ts +113 -0
- package/3p/pi-lsp/extensions/lsp/formatting.ts +315 -0
- package/3p/pi-lsp/extensions/lsp/index.ts +231 -0
- package/3p/pi-lsp/extensions/lsp/protocol.ts +209 -0
- package/3p/pi-lsp/extensions/lsp/retry.ts +39 -0
- package/3p/pi-lsp/extensions/lsp/tools/programs.ts +293 -0
- package/3p/pi-lsp/extensions/lsp/tools.ts +89 -0
- package/3p/pi-lsp/extensions/lsp/types.ts +243 -0
- package/3p/pi-lsp/package.json +54 -0
- package/3p/pi-plannotator/AGENTS.md +583 -0
- package/3p/pi-plannotator/apps/pi-extension/README.md +228 -0
- package/3p/pi-plannotator/apps/pi-extension/assistant-message.ts +128 -0
- package/3p/pi-plannotator/apps/pi-extension/config.test.ts +166 -0
- package/3p/pi-plannotator/apps/pi-extension/config.ts +318 -0
- package/3p/pi-plannotator/apps/pi-extension/current-pi-session.ts +147 -0
- package/3p/pi-plannotator/apps/pi-extension/index.ts +1279 -0
- package/3p/pi-plannotator/apps/pi-extension/package.json +56 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.test.ts +13 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +616 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +342 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator.json +12 -0
- package/3p/pi-plannotator/apps/pi-extension/server/agent-jobs.ts +515 -0
- package/3p/pi-plannotator/apps/pi-extension/server/ai-runtime.ts +169 -0
- package/3p/pi-plannotator/apps/pi-extension/server/annotations.ts +85 -0
- package/3p/pi-plannotator/apps/pi-extension/server/external-annotations.ts +189 -0
- package/3p/pi-plannotator/apps/pi-extension/server/handlers.ts +210 -0
- package/3p/pi-plannotator/apps/pi-extension/server/helpers.ts +78 -0
- package/3p/pi-plannotator/apps/pi-extension/server/ide.ts +46 -0
- package/3p/pi-plannotator/apps/pi-extension/server/integrations.ts +195 -0
- package/3p/pi-plannotator/apps/pi-extension/server/network.test.ts +158 -0
- package/3p/pi-plannotator/apps/pi-extension/server/network.ts +268 -0
- package/3p/pi-plannotator/apps/pi-extension/server/pr.ts +126 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +64 -0
- package/3p/pi-plannotator/apps/pi-extension/server/reference.ts +362 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverAnnotate.ts +199 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverPlan.ts +505 -0
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +1229 -0
- package/3p/pi-plannotator/apps/pi-extension/server/vcs.ts +121 -0
- package/3p/pi-plannotator/apps/pi-extension/server.test.ts +966 -0
- package/3p/pi-plannotator/apps/pi-extension/server.ts +44 -0
- package/3p/pi-plannotator/apps/pi-extension/tool-scope.test.ts +88 -0
- package/3p/pi-plannotator/apps/pi-extension/tool-scope.ts +39 -0
- package/3p/pi-plannotator/apps/pi-extension/tsconfig.json +16 -0
- package/3p/pi-plannotator/apps/pi-extension/vendor.sh +48 -0
- package/3p/pi-plannotator/package.json +53 -0
- package/3p/pi-plannotator/packages/ai/ai.test.ts +1407 -0
- package/3p/pi-plannotator/packages/ai/base-session.ts +94 -0
- package/3p/pi-plannotator/packages/ai/context.ts +250 -0
- package/3p/pi-plannotator/packages/ai/endpoints.ts +326 -0
- package/3p/pi-plannotator/packages/ai/index.ts +105 -0
- package/3p/pi-plannotator/packages/ai/package.json +21 -0
- package/3p/pi-plannotator/packages/ai/provider.ts +103 -0
- package/3p/pi-plannotator/packages/ai/providers/claude-agent-sdk.ts +447 -0
- package/3p/pi-plannotator/packages/ai/providers/codex-sdk.ts +430 -0
- package/3p/pi-plannotator/packages/ai/providers/command-path.ts +115 -0
- package/3p/pi-plannotator/packages/ai/providers/opencode-sdk.ts +546 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-events.ts +110 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-sdk-node.ts +425 -0
- package/3p/pi-plannotator/packages/ai/providers/pi-sdk.ts +469 -0
- package/3p/pi-plannotator/packages/ai/session-manager.ts +195 -0
- package/3p/pi-plannotator/packages/ai/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/ai/types.ts +379 -0
- package/3p/pi-plannotator/packages/server/agent-jobs.ts +538 -0
- package/3p/pi-plannotator/packages/server/agent-review-message.test.ts +135 -0
- package/3p/pi-plannotator/packages/server/agent-review-message.ts +243 -0
- package/3p/pi-plannotator/packages/server/ai-runtime.ts +108 -0
- package/3p/pi-plannotator/packages/server/annotate.ts +401 -0
- package/3p/pi-plannotator/packages/server/browser.test.ts +87 -0
- package/3p/pi-plannotator/packages/server/browser.ts +245 -0
- package/3p/pi-plannotator/packages/server/claude-review.ts +352 -0
- package/3p/pi-plannotator/packages/server/code-nav.ts +73 -0
- package/3p/pi-plannotator/packages/server/codex-review-schema.json +41 -0
- package/3p/pi-plannotator/packages/server/codex-review.ts +330 -0
- package/3p/pi-plannotator/packages/server/config.ts +8 -0
- package/3p/pi-plannotator/packages/server/draft.ts +1 -0
- package/3p/pi-plannotator/packages/server/editor-annotations.ts +76 -0
- package/3p/pi-plannotator/packages/server/external-annotations.test.ts +18 -0
- package/3p/pi-plannotator/packages/server/external-annotations.ts +207 -0
- package/3p/pi-plannotator/packages/server/git.ts +141 -0
- package/3p/pi-plannotator/packages/server/goal-setup.test.ts +55 -0
- package/3p/pi-plannotator/packages/server/goal-setup.ts +248 -0
- package/3p/pi-plannotator/packages/server/ide.ts +43 -0
- package/3p/pi-plannotator/packages/server/image.test.ts +63 -0
- package/3p/pi-plannotator/packages/server/image.ts +66 -0
- package/3p/pi-plannotator/packages/server/index.ts +648 -0
- package/3p/pi-plannotator/packages/server/integrations.test.ts +172 -0
- package/3p/pi-plannotator/packages/server/integrations.ts +214 -0
- package/3p/pi-plannotator/packages/server/jj.test.ts +69 -0
- package/3p/pi-plannotator/packages/server/jj.ts +86 -0
- package/3p/pi-plannotator/packages/server/p4.ts +417 -0
- package/3p/pi-plannotator/packages/server/package.json +40 -0
- package/3p/pi-plannotator/packages/server/path-utils.ts +18 -0
- package/3p/pi-plannotator/packages/server/pr.ts +146 -0
- package/3p/pi-plannotator/packages/server/project.test.ts +115 -0
- package/3p/pi-plannotator/packages/server/project.ts +45 -0
- package/3p/pi-plannotator/packages/server/reference-handlers.ts +404 -0
- package/3p/pi-plannotator/packages/server/remote.test.ts +150 -0
- package/3p/pi-plannotator/packages/server/remote.ts +74 -0
- package/3p/pi-plannotator/packages/server/repo.ts +80 -0
- package/3p/pi-plannotator/packages/server/resolve-file.test.ts +310 -0
- package/3p/pi-plannotator/packages/server/review-workspace.test.ts +1032 -0
- package/3p/pi-plannotator/packages/server/review-workspace.ts +48 -0
- package/3p/pi-plannotator/packages/server/review.ts +1318 -0
- package/3p/pi-plannotator/packages/server/sessions.ts +111 -0
- package/3p/pi-plannotator/packages/server/share-url.ts +53 -0
- package/3p/pi-plannotator/packages/server/shared-handlers.test.ts +43 -0
- package/3p/pi-plannotator/packages/server/shared-handlers.ts +177 -0
- package/3p/pi-plannotator/packages/server/storage.test.ts +176 -0
- package/3p/pi-plannotator/packages/server/storage.ts +17 -0
- package/3p/pi-plannotator/packages/server/tour/tour-review.test.ts +146 -0
- package/3p/pi-plannotator/packages/server/tour/tour-review.ts +604 -0
- package/3p/pi-plannotator/packages/server/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/server/vcs.test.ts +48 -0
- package/3p/pi-plannotator/packages/server/vcs.ts +80 -0
- package/3p/pi-plannotator/packages/shared/agent-jobs.ts +132 -0
- package/3p/pi-plannotator/packages/shared/agents.ts +53 -0
- package/3p/pi-plannotator/packages/shared/annotate-args.test.ts +386 -0
- package/3p/pi-plannotator/packages/shared/annotate-args.ts +107 -0
- package/3p/pi-plannotator/packages/shared/at-reference.test.ts +99 -0
- package/3p/pi-plannotator/packages/shared/at-reference.ts +52 -0
- package/3p/pi-plannotator/packages/shared/checklist.ts +52 -0
- package/3p/pi-plannotator/packages/shared/code-file.test.ts +112 -0
- package/3p/pi-plannotator/packages/shared/code-file.ts +41 -0
- package/3p/pi-plannotator/packages/shared/code-nav.test.ts +515 -0
- package/3p/pi-plannotator/packages/shared/code-nav.ts +436 -0
- package/3p/pi-plannotator/packages/shared/compress.ts +51 -0
- package/3p/pi-plannotator/packages/shared/config.ts +262 -0
- package/3p/pi-plannotator/packages/shared/crypto.test.ts +172 -0
- package/3p/pi-plannotator/packages/shared/crypto.ts +97 -0
- package/3p/pi-plannotator/packages/shared/data-dir.ts +42 -0
- package/3p/pi-plannotator/packages/shared/diff-paths.test.ts +30 -0
- package/3p/pi-plannotator/packages/shared/diff-paths.ts +137 -0
- package/3p/pi-plannotator/packages/shared/draft.ts +64 -0
- package/3p/pi-plannotator/packages/shared/external-annotation.ts +397 -0
- package/3p/pi-plannotator/packages/shared/extract-code-paths.test.ts +59 -0
- package/3p/pi-plannotator/packages/shared/extract-code-paths.ts +66 -0
- package/3p/pi-plannotator/packages/shared/favicon.ts +5 -0
- package/3p/pi-plannotator/packages/shared/feedback-templates.test.ts +65 -0
- package/3p/pi-plannotator/packages/shared/feedback-templates.ts +29 -0
- package/3p/pi-plannotator/packages/shared/goal-setup.test.ts +231 -0
- package/3p/pi-plannotator/packages/shared/goal-setup.ts +336 -0
- package/3p/pi-plannotator/packages/shared/html-to-markdown.test.ts +62 -0
- package/3p/pi-plannotator/packages/shared/html-to-markdown.ts +32 -0
- package/3p/pi-plannotator/packages/shared/improvement-hooks.test.ts +135 -0
- package/3p/pi-plannotator/packages/shared/improvement-hooks.ts +115 -0
- package/3p/pi-plannotator/packages/shared/integrations-common.ts +243 -0
- package/3p/pi-plannotator/packages/shared/jj-core.test.ts +236 -0
- package/3p/pi-plannotator/packages/shared/jj-core.ts +433 -0
- package/3p/pi-plannotator/packages/shared/package.json +55 -0
- package/3p/pi-plannotator/packages/shared/pfm-reminder.test.ts +88 -0
- package/3p/pi-plannotator/packages/shared/pfm-reminder.ts +80 -0
- package/3p/pi-plannotator/packages/shared/pr-github.ts +661 -0
- package/3p/pi-plannotator/packages/shared/pr-gitlab.test.ts +202 -0
- package/3p/pi-plannotator/packages/shared/pr-gitlab.ts +620 -0
- package/3p/pi-plannotator/packages/shared/pr-provider.test.ts +266 -0
- package/3p/pi-plannotator/packages/shared/pr-provider.ts +123 -0
- package/3p/pi-plannotator/packages/shared/pr-stack.test.ts +104 -0
- package/3p/pi-plannotator/packages/shared/pr-stack.ts +194 -0
- package/3p/pi-plannotator/packages/shared/pr-types.ts +326 -0
- package/3p/pi-plannotator/packages/shared/project.ts +71 -0
- package/3p/pi-plannotator/packages/shared/prompts-integration.test.ts +421 -0
- package/3p/pi-plannotator/packages/shared/prompts.test.ts +504 -0
- package/3p/pi-plannotator/packages/shared/prompts.ts +247 -0
- package/3p/pi-plannotator/packages/shared/reference-common.ts +87 -0
- package/3p/pi-plannotator/packages/shared/repo.ts +71 -0
- package/3p/pi-plannotator/packages/shared/resolve-file.test.ts +113 -0
- package/3p/pi-plannotator/packages/shared/resolve-file.ts +509 -0
- package/3p/pi-plannotator/packages/shared/review-args.test.ts +64 -0
- package/3p/pi-plannotator/packages/shared/review-args.ts +85 -0
- package/3p/pi-plannotator/packages/shared/review-core.test.ts +286 -0
- package/3p/pi-plannotator/packages/shared/review-core.ts +895 -0
- package/3p/pi-plannotator/packages/shared/review-workspace-node.ts +230 -0
- package/3p/pi-plannotator/packages/shared/review-workspace.ts +436 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff-types.ts +76 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff.test.ts +322 -0
- package/3p/pi-plannotator/packages/shared/semantic-diff.ts +520 -0
- package/3p/pi-plannotator/packages/shared/storage.ts +378 -0
- package/3p/pi-plannotator/packages/shared/tour.ts +61 -0
- package/3p/pi-plannotator/packages/shared/tsconfig.json +15 -0
- package/3p/pi-plannotator/packages/shared/types.ts +29 -0
- package/3p/pi-plannotator/packages/shared/url-to-markdown.test.ts +177 -0
- package/3p/pi-plannotator/packages/shared/url-to-markdown.ts +351 -0
- package/3p/pi-plannotator/packages/shared/vcs-core.test.ts +332 -0
- package/3p/pi-plannotator/packages/shared/vcs-core.ts +482 -0
- package/3p/pi-plannotator/packages/shared/worktree-pool.test.ts +162 -0
- package/3p/pi-plannotator/packages/shared/worktree-pool.ts +103 -0
- package/3p/pi-plannotator/packages/shared/worktree.ts +119 -0
- package/3p/pi-subagents/package.json +50 -0
- package/3p/pi-subagents/src/agent-manager.ts +413 -0
- package/3p/pi-subagents/src/agent-runner.ts +502 -0
- package/3p/pi-subagents/src/agent-types.ts +248 -0
- package/3p/pi-subagents/src/context.ts +58 -0
- package/3p/pi-subagents/src/cross-extension-rpc.ts +135 -0
- package/3p/pi-subagents/src/custom-agents.ts +137 -0
- package/3p/pi-subagents/src/default-agents.ts +144 -0
- package/3p/pi-subagents/src/env.ts +33 -0
- package/3p/pi-subagents/src/group-join.ts +141 -0
- package/3p/pi-subagents/src/index.ts +1811 -0
- package/3p/pi-subagents/src/invocation-config.ts +40 -0
- package/3p/pi-subagents/src/memory.ts +165 -0
- package/3p/pi-subagents/src/model-resolver.ts +81 -0
- package/3p/pi-subagents/src/output-file.ts +77 -0
- package/3p/pi-subagents/src/prompts.ts +85 -0
- package/3p/pi-subagents/src/skill-loader.ts +79 -0
- package/3p/pi-subagents/src/types.ts +111 -0
- package/3p/pi-subagents/src/ui/agent-widget.ts +496 -0
- package/3p/pi-subagents/src/ui/conversation-viewer.ts +250 -0
- package/3p/pi-subagents/src/worktree.ts +162 -0
- package/3p/pi-tasks/package.json +52 -0
- package/3p/pi-tasks/src/auto-clear.ts +91 -0
- package/3p/pi-tasks/src/index.ts +1150 -0
- package/3p/pi-tasks/src/process-tracker.ts +140 -0
- package/3p/pi-tasks/src/reminder-cadence.ts +90 -0
- package/3p/pi-tasks/src/task-store.ts +324 -0
- package/3p/pi-tasks/src/tasks-config.ts +27 -0
- package/3p/pi-tasks/src/types.ts +40 -0
- package/3p/pi-tasks/src/ui/settings-menu.ts +152 -0
- package/3p/pi-tasks/src/ui/task-widget.ts +296 -0
- package/AGENTS.md +28 -0
- package/LICENSE +201 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +77 -0
- package/extensions/orchestrator/agents/code-reviewer.ts +89 -0
- package/extensions/orchestrator/agents/explore.ts +33 -0
- package/extensions/orchestrator/agents/librarian.ts +43 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +75 -0
- package/extensions/orchestrator/agents/planner.ts +61 -0
- package/extensions/orchestrator/agents/registry.ts +156 -0
- package/extensions/orchestrator/agents/task.ts +50 -0
- package/extensions/orchestrator/agents/tool-routing.ts +84 -0
- package/extensions/orchestrator/ast-search.ts +85 -0
- package/extensions/orchestrator/cbm.ts +330 -0
- package/extensions/orchestrator/command-handlers.test.ts +163 -0
- package/extensions/orchestrator/command-handlers.ts +116 -0
- package/extensions/orchestrator/commands.test.ts +81 -0
- package/extensions/orchestrator/commands.ts +75 -0
- package/extensions/orchestrator/config.test.ts +221 -0
- package/extensions/orchestrator/config.ts +230 -0
- package/extensions/orchestrator/context.test.ts +293 -0
- package/extensions/orchestrator/context.ts +206 -0
- package/extensions/orchestrator/event-handlers.test.ts +190 -0
- package/extensions/orchestrator/event-handlers.ts +1416 -0
- package/extensions/orchestrator/exa.ts +104 -0
- package/extensions/orchestrator/flant-infra.ts +486 -0
- package/extensions/orchestrator/index.ts +80 -0
- package/extensions/orchestrator/integration.test.ts +1214 -0
- package/extensions/orchestrator/orchestrator.test.ts +252 -0
- package/extensions/orchestrator/orchestrator.ts +538 -0
- package/extensions/orchestrator/phases/brainstorm.test.ts +15 -0
- package/extensions/orchestrator/phases/brainstorm.ts +273 -0
- package/extensions/orchestrator/phases/implementation.ts +40 -0
- package/extensions/orchestrator/phases/machine.test.ts +293 -0
- package/extensions/orchestrator/phases/machine.ts +209 -0
- package/extensions/orchestrator/phases/planning.ts +255 -0
- package/extensions/orchestrator/phases/review.ts +193 -0
- package/extensions/orchestrator/plannotator.ts +56 -0
- package/extensions/orchestrator/pp-menu.ts +866 -0
- package/extensions/orchestrator/state.test.ts +343 -0
- package/extensions/orchestrator/state.ts +237 -0
- package/extensions/orchestrator/validate-artifacts.test.ts +88 -0
- package/extensions/orchestrator/validate-artifacts.ts +272 -0
- package/extensions/orchestrator/vendor.d.ts +26 -0
- package/package.json +73 -0
- package/scripts/postinstall.sh +18 -0
|
@@ -0,0 +1,1835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask Tool Extension - Interactive question UI for pi-coding-agent
|
|
3
|
+
*
|
|
4
|
+
* Refactored to use built-in TUI primitives (Container/Text/Spacer/SelectList/Editor)
|
|
5
|
+
* and a custom box border instead of manual ANSI box drawing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type, type TUnsafe } from "@sinclair/typebox";
|
|
11
|
+
import {
|
|
12
|
+
Container,
|
|
13
|
+
type Component,
|
|
14
|
+
decodeKittyPrintable,
|
|
15
|
+
Editor,
|
|
16
|
+
type EditorTheme,
|
|
17
|
+
fuzzyFilter,
|
|
18
|
+
Key,
|
|
19
|
+
type Keybinding,
|
|
20
|
+
type KeybindingsManager,
|
|
21
|
+
Markdown,
|
|
22
|
+
type MarkdownTheme,
|
|
23
|
+
matchesKey,
|
|
24
|
+
type OverlayHandle,
|
|
25
|
+
Spacer,
|
|
26
|
+
Text,
|
|
27
|
+
type TUI,
|
|
28
|
+
truncateToWidth,
|
|
29
|
+
wrapTextWithAnsi,
|
|
30
|
+
} from "@earendil-works/pi-tui";
|
|
31
|
+
import { renderSingleSelectRows, type QuestionOption } from "./single-select-layout";
|
|
32
|
+
export type { QuestionOption } from "./single-select-layout";
|
|
33
|
+
|
|
34
|
+
import { createRequire } from "node:module";
|
|
35
|
+
const _require = createRequire(import.meta.url);
|
|
36
|
+
const ASK_USER_VERSION: string = (_require("./package.json") as { version: string }).version;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit a flat `{ type: "string", enum: [...] }` JSON Schema instead of the
|
|
40
|
+
* `anyOf`/`oneOf` shape that `Type.Union([Type.Literal()])` produces. Google's
|
|
41
|
+
* function-calling API rejects the union form. Local copy of pi-ai's StringEnum
|
|
42
|
+
* to avoid a peer dependency for one helper.
|
|
43
|
+
*/
|
|
44
|
+
function StringEnum<const T extends readonly string[]>(
|
|
45
|
+
values: T,
|
|
46
|
+
options?: { description?: string; default?: T[number] },
|
|
47
|
+
): TUnsafe<T[number]> {
|
|
48
|
+
return Type.Unsafe<T[number]>({
|
|
49
|
+
type: "string",
|
|
50
|
+
enum: [...values],
|
|
51
|
+
...(options?.description ? { description: options.description } : {}),
|
|
52
|
+
...(options?.default !== undefined ? { default: options.default } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `getMarkdownTheme()` returns a bag of closures that read through a Proxy
|
|
58
|
+
* over the host's theme singleton. The Proxy only throws on property access,
|
|
59
|
+
* not when the bag itself is constructed — so a naive
|
|
60
|
+
* `try { getMarkdownTheme() } catch {}` silently lets a broken bag escape
|
|
61
|
+
* and crashes mid-render the first time pi-tui's Markdown calls
|
|
62
|
+
* `mdTheme.bold(...)`.
|
|
63
|
+
*
|
|
64
|
+
* That broken-bag scenario shows up whenever this extension's bundled copy
|
|
65
|
+
* of `@earendil-works/pi-coding-agent` is a different module instance than
|
|
66
|
+
* the host's — e.g. an older Pi still on the legacy
|
|
67
|
+
* `@mariozechner/pi-coding-agent` scope (≤ 0.73.1) where npm cannot dedupe
|
|
68
|
+
* across scopes, so our copy's theme singleton is never initialised
|
|
69
|
+
* (`globalThis[Symbol.for("@earendil-works/pi-coding-agent:theme")]` is
|
|
70
|
+
* undefined). See https://github.com/edlsh/pi-ask-user/issues/17.
|
|
71
|
+
*
|
|
72
|
+
* Probe `bold("")` to force the Proxy lookup eagerly; on throw, callers
|
|
73
|
+
* fall back to plain `Text` rendering for context blocks.
|
|
74
|
+
*/
|
|
75
|
+
function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
76
|
+
try {
|
|
77
|
+
const md = getMarkdownTheme();
|
|
78
|
+
if (!md) return undefined;
|
|
79
|
+
md.bold("");
|
|
80
|
+
return md;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type AskOptionInput = QuestionOption | string;
|
|
87
|
+
|
|
88
|
+
type AskDisplayMode = "overlay" | "inline";
|
|
89
|
+
|
|
90
|
+
interface AskParams {
|
|
91
|
+
question: string;
|
|
92
|
+
context?: string;
|
|
93
|
+
options?: AskOptionInput[];
|
|
94
|
+
allowMultiple?: boolean;
|
|
95
|
+
allowFreeform?: boolean;
|
|
96
|
+
allowComment?: boolean;
|
|
97
|
+
displayMode?: AskDisplayMode;
|
|
98
|
+
overlayToggleKey?: string | null;
|
|
99
|
+
commentToggleKey?: string | null;
|
|
100
|
+
timeout?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type AskResponse =
|
|
104
|
+
| {
|
|
105
|
+
kind: "selection";
|
|
106
|
+
selections: string[];
|
|
107
|
+
comment?: string;
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
kind: "freeform";
|
|
111
|
+
text: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
interface AskToolDetails {
|
|
115
|
+
question: string;
|
|
116
|
+
context?: string;
|
|
117
|
+
options: QuestionOption[];
|
|
118
|
+
response: AskResponse | null;
|
|
119
|
+
cancelled: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type AskUIResult = AskResponse;
|
|
123
|
+
|
|
124
|
+
function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
|
|
125
|
+
return options
|
|
126
|
+
.map((option) => {
|
|
127
|
+
if (typeof option === "string") {
|
|
128
|
+
return { title: option };
|
|
129
|
+
}
|
|
130
|
+
if (option && typeof option === "object" && typeof option.title === "string") {
|
|
131
|
+
return { title: option.title, description: option.description };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
})
|
|
135
|
+
.filter((option): option is QuestionOption => option !== null);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatOptionsForMessage(options: QuestionOption[]): string {
|
|
139
|
+
return options
|
|
140
|
+
.map((option, index) => {
|
|
141
|
+
const desc = option.description ? ` — ${option.description}` : "";
|
|
142
|
+
return `${index + 1}. ${option.title}${desc}`;
|
|
143
|
+
})
|
|
144
|
+
.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeOptionalComment(text: string | null | undefined): string | undefined {
|
|
148
|
+
const trimmed = text?.trim();
|
|
149
|
+
return trimmed ? trimmed : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createFreeformResponse(text: string | null | undefined): AskResponse | null {
|
|
153
|
+
const trimmed = text?.trim();
|
|
154
|
+
return trimmed ? { kind: "freeform", text: trimmed } : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createSelectionResponse(selections: string[], comment?: string | null): AskResponse | null {
|
|
158
|
+
const normalizedSelections = selections.map((selection) => selection.trim()).filter(Boolean);
|
|
159
|
+
if (normalizedSelections.length === 0) return null;
|
|
160
|
+
|
|
161
|
+
const normalizedComment = normalizeOptionalComment(comment);
|
|
162
|
+
return normalizedComment
|
|
163
|
+
? { kind: "selection", selections: normalizedSelections, comment: normalizedComment }
|
|
164
|
+
: { kind: "selection", selections: normalizedSelections };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatResponseSummary(response: AskResponse): string {
|
|
168
|
+
if (response.kind === "freeform") return response.text;
|
|
169
|
+
|
|
170
|
+
const selections = response.selections.join(", ");
|
|
171
|
+
return response.comment ? `${selections} — ${response.comment}` : selections;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildCommentPrompt(prompt: string, selections: string[]): string {
|
|
175
|
+
const label = selections.length === 1 ? "Selected option" : "Selected options";
|
|
176
|
+
const lines = selections.map((selection) => `- ${selection}`).join("\n");
|
|
177
|
+
return `${prompt}\n\n${label}:\n${lines}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseDialogSelections(input: string): string[] {
|
|
181
|
+
return input
|
|
182
|
+
.split(",")
|
|
183
|
+
.map((selection) => selection.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isCancelledInput(value: unknown): value is null | undefined {
|
|
188
|
+
return value === null || value === undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isSelectionResponse(response: AskResponse): response is Extract<AskResponse, { kind: "selection" }> {
|
|
192
|
+
return response.kind === "selection";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createSelectListTheme(theme: Theme) {
|
|
196
|
+
return {
|
|
197
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
198
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
199
|
+
description: (t: string) => theme.fg("muted", t),
|
|
200
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
201
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createEditorTheme(theme: Theme): EditorTheme {
|
|
206
|
+
return {
|
|
207
|
+
borderColor: (s: string) => theme.fg("accent", s),
|
|
208
|
+
selectList: createSelectListTheme(theme),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const BOX_BORDER_LEFT = "│ ";
|
|
213
|
+
const BOX_BORDER_RIGHT = " │";
|
|
214
|
+
const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
|
|
215
|
+
|
|
216
|
+
class BoxBorderTop implements Component {
|
|
217
|
+
private color: (s: string) => string;
|
|
218
|
+
private title?: string;
|
|
219
|
+
private titleColor?: (s: string) => string;
|
|
220
|
+
constructor(color: (s: string) => string, title?: string, titleColor?: (s: string) => string) {
|
|
221
|
+
this.color = color;
|
|
222
|
+
this.title = title;
|
|
223
|
+
this.titleColor = titleColor;
|
|
224
|
+
}
|
|
225
|
+
invalidate(): void { }
|
|
226
|
+
render(width: number): string[] {
|
|
227
|
+
const inner = Math.max(0, width - 2);
|
|
228
|
+
if (!this.title || inner < this.title.length + 4) {
|
|
229
|
+
return [this.color(`╭${"─".repeat(inner)}╮`)];
|
|
230
|
+
}
|
|
231
|
+
const label = ` ${this.title} `;
|
|
232
|
+
const remaining = inner - 1 - label.length;
|
|
233
|
+
const titleStyle = this.titleColor ?? this.color;
|
|
234
|
+
return [
|
|
235
|
+
this.color("╭─") + titleStyle(label) + this.color("─".repeat(Math.max(0, remaining)) + "╮"),
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
class BoxBorderBottom implements Component {
|
|
241
|
+
private color: (s: string) => string;
|
|
242
|
+
private label?: string;
|
|
243
|
+
private labelColor?: (s: string) => string;
|
|
244
|
+
constructor(color: (s: string) => string, label?: string, labelColor?: (s: string) => string) {
|
|
245
|
+
this.color = color;
|
|
246
|
+
this.label = label;
|
|
247
|
+
this.labelColor = labelColor;
|
|
248
|
+
}
|
|
249
|
+
invalidate(): void { }
|
|
250
|
+
render(width: number): string[] {
|
|
251
|
+
const inner = Math.max(0, width - 2);
|
|
252
|
+
if (!this.label || inner < this.label.length + 4) {
|
|
253
|
+
return [this.color(`╰${"─".repeat(inner)}╯`)];
|
|
254
|
+
}
|
|
255
|
+
const tag = ` ${this.label} `;
|
|
256
|
+
const leftDashes = inner - tag.length - 1;
|
|
257
|
+
const style = this.labelColor ?? this.color;
|
|
258
|
+
return [
|
|
259
|
+
this.color("╰" + "─".repeat(Math.max(0, leftDashes))) + style(tag) + this.color("─╯"),
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function formatKeyList(keys: string[]): string {
|
|
265
|
+
return keys.join("/");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function keybindingHint(
|
|
269
|
+
theme: Theme,
|
|
270
|
+
keybindings: KeybindingsManager,
|
|
271
|
+
keybinding: Keybinding,
|
|
272
|
+
description: string,
|
|
273
|
+
): string {
|
|
274
|
+
return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function literalHint(theme: Theme, key: string, description: string): string {
|
|
278
|
+
return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
type ResolvedShortcut =
|
|
282
|
+
| { disabled: false; spec: string; matches: (data: string) => boolean }
|
|
283
|
+
| { disabled: true; spec: null; matches: (data: string) => false };
|
|
284
|
+
|
|
285
|
+
interface ResolvedAskShortcuts {
|
|
286
|
+
overlayToggle: ResolvedShortcut;
|
|
287
|
+
commentToggle: ResolvedShortcut;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const DISABLED_SHORTCUT: ResolvedShortcut = {
|
|
291
|
+
disabled: true,
|
|
292
|
+
spec: null,
|
|
293
|
+
matches: ((_data: string) => false) as (data: string) => false,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const SHORTCUT_DISABLE_VALUES = new Set(["off", "none", "disabled", ""]);
|
|
297
|
+
|
|
298
|
+
function normalizeShortcutSpec(value: string | null | undefined): string | null | undefined {
|
|
299
|
+
if (value === undefined) return undefined;
|
|
300
|
+
if (value === null) return null;
|
|
301
|
+
const trimmed = value.trim().toLowerCase();
|
|
302
|
+
if (SHORTCUT_DISABLE_VALUES.has(trimmed)) return null;
|
|
303
|
+
return trimmed;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isValidShortcutSpec(spec: string): boolean {
|
|
307
|
+
// KeyId is canonical lowercase: modifiers (`ctrl|shift|alt|super`) joined by `+`,
|
|
308
|
+
// plus a base key. We do a light syntactic sanity check; matchesKey() does the rest.
|
|
309
|
+
if (!spec) return false;
|
|
310
|
+
if (!/^[a-z0-9+_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]+$/i.test(spec)) return false;
|
|
311
|
+
if (spec.startsWith("+") || spec.endsWith("+")) return false;
|
|
312
|
+
if (spec.includes("++")) return false;
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildShortcut(spec: string): ResolvedShortcut {
|
|
317
|
+
return {
|
|
318
|
+
disabled: false,
|
|
319
|
+
spec,
|
|
320
|
+
matches: (data: string) => matchesKey(data, spec as any),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function resolveShortcut(
|
|
325
|
+
paramValue: string | null | undefined,
|
|
326
|
+
envValue: string | undefined,
|
|
327
|
+
defaultSpec: string,
|
|
328
|
+
): ResolvedShortcut {
|
|
329
|
+
const candidates: Array<string | null | undefined> = [paramValue, envValue, defaultSpec];
|
|
330
|
+
for (const raw of candidates) {
|
|
331
|
+
const normalized = normalizeShortcutSpec(raw);
|
|
332
|
+
if (normalized === undefined) continue; // not provided, fall through
|
|
333
|
+
if (normalized === null) return DISABLED_SHORTCUT; // explicit disable
|
|
334
|
+
if (isValidShortcutSpec(normalized)) return buildShortcut(normalized);
|
|
335
|
+
// Invalid spec: silently fall through to next candidate.
|
|
336
|
+
}
|
|
337
|
+
return DISABLED_SHORTCUT;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type AskMode = "select" | "freeform" | "comment";
|
|
341
|
+
|
|
342
|
+
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
343
|
+
const ASK_OVERLAY_WIDTH = "92%";
|
|
344
|
+
const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
345
|
+
const SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH = 84;
|
|
346
|
+
const SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH = 32;
|
|
347
|
+
const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
|
|
348
|
+
const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
|
|
349
|
+
const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response...";
|
|
350
|
+
const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
|
|
351
|
+
const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
|
|
352
|
+
const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
|
|
353
|
+
|
|
354
|
+
// Vim-style aliases for navigating option lists. ctrl+j/k are safe in the
|
|
355
|
+
// searchable single-select because they don't collide with fuzzy-search input.
|
|
356
|
+
const VIM_SELECT_UP_KEY = Key.ctrl("k");
|
|
357
|
+
const VIM_SELECT_DOWN_KEY = Key.ctrl("j");
|
|
358
|
+
|
|
359
|
+
function matchesSelectUp(data: string, keybindings: KeybindingsManager): boolean {
|
|
360
|
+
return (
|
|
361
|
+
keybindings.matches(data, "tui.select.up") ||
|
|
362
|
+
matchesKey(data, Key.shift("tab")) ||
|
|
363
|
+
matchesKey(data, VIM_SELECT_UP_KEY)
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function matchesSelectDown(data: string, keybindings: KeybindingsManager): boolean {
|
|
368
|
+
return (
|
|
369
|
+
keybindings.matches(data, "tui.select.down") ||
|
|
370
|
+
matchesKey(data, Key.tab) ||
|
|
371
|
+
matchesKey(data, VIM_SELECT_DOWN_KEY)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function buildCustomUIOptions(
|
|
376
|
+
displayMode: AskDisplayMode,
|
|
377
|
+
onHandle?: (handle: OverlayHandle) => void,
|
|
378
|
+
) {
|
|
379
|
+
switch (displayMode) {
|
|
380
|
+
case "inline":
|
|
381
|
+
return undefined;
|
|
382
|
+
case "overlay":
|
|
383
|
+
return {
|
|
384
|
+
overlay: true,
|
|
385
|
+
overlayOptions: {
|
|
386
|
+
anchor: "center" as const,
|
|
387
|
+
width: ASK_OVERLAY_WIDTH,
|
|
388
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
389
|
+
maxHeight: "85%",
|
|
390
|
+
margin: 1,
|
|
391
|
+
},
|
|
392
|
+
...(onHandle ? { onHandle } : {}),
|
|
393
|
+
};
|
|
394
|
+
default: {
|
|
395
|
+
const _exhaustive: never = displayMode;
|
|
396
|
+
void _exhaustive;
|
|
397
|
+
return {
|
|
398
|
+
overlay: true,
|
|
399
|
+
overlayOptions: {
|
|
400
|
+
anchor: "center" as const,
|
|
401
|
+
width: ASK_OVERLAY_WIDTH,
|
|
402
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
403
|
+
maxHeight: "85%",
|
|
404
|
+
margin: 1,
|
|
405
|
+
},
|
|
406
|
+
...(onHandle ? { onHandle } : {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
class MultiSelectList implements Component {
|
|
413
|
+
private options: QuestionOption[];
|
|
414
|
+
private allowFreeform: boolean;
|
|
415
|
+
private allowComment: boolean;
|
|
416
|
+
private theme: Theme;
|
|
417
|
+
private keybindings: KeybindingsManager;
|
|
418
|
+
private commentToggle: ResolvedShortcut;
|
|
419
|
+
private selectedIndex = 0;
|
|
420
|
+
private checked = new Set<number>();
|
|
421
|
+
private commentEnabled = false;
|
|
422
|
+
private cachedWidth?: number;
|
|
423
|
+
private cachedLines?: string[];
|
|
424
|
+
|
|
425
|
+
public onCancel?: () => void;
|
|
426
|
+
public onSubmit?: (result: string[]) => void;
|
|
427
|
+
public onEnterFreeform?: () => void;
|
|
428
|
+
|
|
429
|
+
constructor(
|
|
430
|
+
options: QuestionOption[],
|
|
431
|
+
allowFreeform: boolean,
|
|
432
|
+
allowComment: boolean,
|
|
433
|
+
theme: Theme,
|
|
434
|
+
keybindings: KeybindingsManager,
|
|
435
|
+
commentToggle: ResolvedShortcut,
|
|
436
|
+
) {
|
|
437
|
+
this.options = options;
|
|
438
|
+
this.allowFreeform = allowFreeform;
|
|
439
|
+
this.allowComment = allowComment;
|
|
440
|
+
this.theme = theme;
|
|
441
|
+
this.keybindings = keybindings;
|
|
442
|
+
this.commentToggle = commentToggle;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
public isCommentEnabled(): boolean {
|
|
446
|
+
return this.commentEnabled;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
invalidate(): void {
|
|
450
|
+
this.cachedWidth = undefined;
|
|
451
|
+
this.cachedLines = undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private getItemCount(): number {
|
|
455
|
+
return this.options.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private getCommentToggleIndex(): number | null {
|
|
459
|
+
return this.allowComment ? this.options.length : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private getFreeformIndex(): number {
|
|
463
|
+
return this.options.length + (this.allowComment ? 1 : 0);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private isCommentToggleRow(index: number): boolean {
|
|
467
|
+
const toggleIndex = this.getCommentToggleIndex();
|
|
468
|
+
return toggleIndex !== null && index === toggleIndex;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private isFreeformRow(index: number): boolean {
|
|
472
|
+
return this.allowFreeform && index === this.getFreeformIndex();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private toggle(index: number): void {
|
|
476
|
+
if (index < 0 || index >= this.options.length) return;
|
|
477
|
+
if (this.checked.has(index)) this.checked.delete(index);
|
|
478
|
+
else this.checked.add(index);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private toggleComment(): void {
|
|
482
|
+
if (!this.allowComment) return;
|
|
483
|
+
this.commentEnabled = !this.commentEnabled;
|
|
484
|
+
this.invalidate();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
handleInput(data: string): void {
|
|
488
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
489
|
+
this.onCancel?.();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const count = this.getItemCount();
|
|
494
|
+
if (count === 0) {
|
|
495
|
+
this.onCancel?.();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
|
|
500
|
+
this.toggleComment();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (matchesSelectUp(data, this.keybindings)) {
|
|
505
|
+
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
506
|
+
this.invalidate();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (matchesSelectDown(data, this.keybindings)) {
|
|
511
|
+
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
512
|
+
this.invalidate();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
517
|
+
if (numMatch) {
|
|
518
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
519
|
+
if (idx >= 0 && idx < this.options.length) {
|
|
520
|
+
this.toggle(idx);
|
|
521
|
+
this.selectedIndex = Math.min(idx, count - 1);
|
|
522
|
+
this.invalidate();
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (matchesKey(data, Key.space)) {
|
|
528
|
+
if (this.isCommentToggleRow(this.selectedIndex)) {
|
|
529
|
+
this.toggleComment();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
533
|
+
this.onEnterFreeform?.();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.toggle(this.selectedIndex);
|
|
537
|
+
this.invalidate();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
542
|
+
if (this.isCommentToggleRow(this.selectedIndex)) {
|
|
543
|
+
this.toggleComment();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
547
|
+
this.onEnterFreeform?.();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const selectedTitles = Array.from(this.checked)
|
|
552
|
+
.sort((a, b) => a - b)
|
|
553
|
+
.map((i) => this.options[i]?.title)
|
|
554
|
+
.filter((t): t is string => !!t);
|
|
555
|
+
|
|
556
|
+
const fallback = this.options[this.selectedIndex]?.title;
|
|
557
|
+
const result = selectedTitles.length > 0 ? selectedTitles : fallback ? [fallback] : [];
|
|
558
|
+
|
|
559
|
+
if (result.length > 0) this.onSubmit?.(result);
|
|
560
|
+
else this.onCancel?.();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
render(width: number): string[] {
|
|
565
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
566
|
+
return this.cachedLines;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const theme = this.theme;
|
|
570
|
+
const count = this.getItemCount();
|
|
571
|
+
const maxVisible = Math.min(count, 10);
|
|
572
|
+
|
|
573
|
+
if (count === 0) {
|
|
574
|
+
this.cachedLines = [theme.fg("warning", "No options")];
|
|
575
|
+
this.cachedWidth = width;
|
|
576
|
+
return this.cachedLines;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
|
|
580
|
+
const endIndex = Math.min(startIndex + maxVisible, count);
|
|
581
|
+
|
|
582
|
+
const lines: string[] = [];
|
|
583
|
+
|
|
584
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
585
|
+
const isSelected = i === this.selectedIndex;
|
|
586
|
+
const prefix = isSelected ? theme.fg("accent", "→") : " ";
|
|
587
|
+
|
|
588
|
+
if (this.isCommentToggleRow(i)) {
|
|
589
|
+
const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
590
|
+
const label = isSelected
|
|
591
|
+
? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL))
|
|
592
|
+
: theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
|
|
593
|
+
lines.push(truncateToWidth(`${prefix} ${checkbox} ${label}`, width, ""));
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (this.isFreeformRow(i)) {
|
|
598
|
+
const label = theme.fg("text", theme.bold("Type something."));
|
|
599
|
+
const desc = theme.fg("muted", "Enter a custom response");
|
|
600
|
+
const line = `${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`;
|
|
601
|
+
lines.push(truncateToWidth(line, width, ""));
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const option = this.options[i];
|
|
606
|
+
if (!option) continue;
|
|
607
|
+
|
|
608
|
+
const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
609
|
+
const num = theme.fg("dim", `${i + 1}.`);
|
|
610
|
+
const title = isSelected
|
|
611
|
+
? theme.fg("accent", theme.bold(option.title))
|
|
612
|
+
: theme.fg("text", theme.bold(option.title));
|
|
613
|
+
|
|
614
|
+
const firstLine = `${prefix} ${num} ${checkbox} ${title}`;
|
|
615
|
+
lines.push(truncateToWidth(firstLine, width, ""));
|
|
616
|
+
|
|
617
|
+
if (option.description) {
|
|
618
|
+
const indent = " ";
|
|
619
|
+
const wrapWidth = Math.max(10, width - indent.length);
|
|
620
|
+
const wrapped = wrapTextWithAnsi(option.description, wrapWidth);
|
|
621
|
+
for (const w of wrapped) {
|
|
622
|
+
lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (startIndex > 0 || endIndex < count) {
|
|
628
|
+
lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
this.cachedWidth = width;
|
|
632
|
+
this.cachedLines = lines;
|
|
633
|
+
return lines;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
class WrappedSingleSelectList implements Component {
|
|
638
|
+
private options: QuestionOption[];
|
|
639
|
+
private allowFreeform: boolean;
|
|
640
|
+
private allowComment: boolean;
|
|
641
|
+
private theme: Theme;
|
|
642
|
+
private keybindings: KeybindingsManager;
|
|
643
|
+
private commentToggle: ResolvedShortcut;
|
|
644
|
+
private selectedIndex = 0;
|
|
645
|
+
private searchQuery = "";
|
|
646
|
+
private commentEnabled = false;
|
|
647
|
+
private maxVisibleRows = 12;
|
|
648
|
+
private cachedWidth?: number;
|
|
649
|
+
private cachedLines?: string[];
|
|
650
|
+
|
|
651
|
+
public onCancel?: () => void;
|
|
652
|
+
public onSubmit?: (result: string) => void;
|
|
653
|
+
public onEnterFreeform?: () => void;
|
|
654
|
+
|
|
655
|
+
constructor(
|
|
656
|
+
options: QuestionOption[],
|
|
657
|
+
allowFreeform: boolean,
|
|
658
|
+
allowComment: boolean,
|
|
659
|
+
theme: Theme,
|
|
660
|
+
keybindings: KeybindingsManager,
|
|
661
|
+
commentToggle: ResolvedShortcut,
|
|
662
|
+
) {
|
|
663
|
+
this.options = options;
|
|
664
|
+
this.allowFreeform = allowFreeform;
|
|
665
|
+
this.allowComment = allowComment;
|
|
666
|
+
this.theme = theme;
|
|
667
|
+
this.keybindings = keybindings;
|
|
668
|
+
this.commentToggle = commentToggle;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
public isCommentEnabled(): boolean {
|
|
672
|
+
return this.commentEnabled;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
setMaxVisibleRows(rows: number): void {
|
|
676
|
+
const next = Math.max(1, Math.floor(rows));
|
|
677
|
+
if (next !== this.maxVisibleRows) {
|
|
678
|
+
this.maxVisibleRows = next;
|
|
679
|
+
this.invalidate();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
invalidate(): void {
|
|
684
|
+
this.cachedWidth = undefined;
|
|
685
|
+
this.cachedLines = undefined;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private getFilteredOptions(): QuestionOption[] {
|
|
689
|
+
return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private getItemCount(filteredOptions: QuestionOption[]): number {
|
|
693
|
+
return filteredOptions.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private isCommentToggleRow(index: number, filteredOptions: QuestionOption[]): boolean {
|
|
697
|
+
return this.allowComment && index === filteredOptions.length;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private isFreeformRow(index: number, filteredOptions: QuestionOption[]): boolean {
|
|
701
|
+
return this.allowFreeform && index === filteredOptions.length + (this.allowComment ? 1 : 0);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private toggleComment(): void {
|
|
705
|
+
if (!this.allowComment) return;
|
|
706
|
+
this.commentEnabled = !this.commentEnabled;
|
|
707
|
+
this.invalidate();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private setSearchQuery(query: string): void {
|
|
711
|
+
this.searchQuery = query;
|
|
712
|
+
this.selectedIndex = 0;
|
|
713
|
+
this.invalidate();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
private popSearchCharacter(): void {
|
|
717
|
+
if (!this.searchQuery) return;
|
|
718
|
+
const characters = [...this.searchQuery];
|
|
719
|
+
characters.pop();
|
|
720
|
+
this.setSearchQuery(characters.join(""));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private getPrintableInput(data: string): string | null {
|
|
724
|
+
const kittyPrintable = decodeKittyPrintable(data);
|
|
725
|
+
if (kittyPrintable !== undefined) return kittyPrintable;
|
|
726
|
+
|
|
727
|
+
const characters = [...data];
|
|
728
|
+
if (characters.length !== 1) return null;
|
|
729
|
+
|
|
730
|
+
const [character] = characters;
|
|
731
|
+
if (!character) return null;
|
|
732
|
+
|
|
733
|
+
const code = character.charCodeAt(0);
|
|
734
|
+
if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return character;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private styleListLine(line: string, width: number, isSelected: boolean): string {
|
|
742
|
+
const trimmed = line.trim();
|
|
743
|
+
|
|
744
|
+
if (trimmed.startsWith("(")) {
|
|
745
|
+
return truncateToWidth(this.theme.fg("dim", line), width, "");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (isSelected) {
|
|
749
|
+
return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (line.startsWith(" ")) {
|
|
753
|
+
return truncateToWidth(this.theme.fg("muted", line), width, "");
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (line.startsWith("→")) {
|
|
757
|
+
return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return truncateToWidth(this.theme.fg("text", line), width, "");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private getSplitPaneWidths(width: number): { left: number; right: number } | null {
|
|
764
|
+
if (width < SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH) return null;
|
|
765
|
+
|
|
766
|
+
const availableWidth = width - SINGLE_SELECT_SPLIT_PANE_SEPARATOR.length;
|
|
767
|
+
if (availableWidth < SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH + SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const preferredLeftWidth = Math.floor(availableWidth * 0.42);
|
|
772
|
+
const left = Math.max(
|
|
773
|
+
SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH,
|
|
774
|
+
Math.min(preferredLeftWidth, availableWidth - SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH),
|
|
775
|
+
);
|
|
776
|
+
const right = availableWidth - left;
|
|
777
|
+
|
|
778
|
+
if (right < SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
|
|
779
|
+
return { left, right };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private buildListLines(width: number, filteredOptions: QuestionOption[], hideDescriptions = false): string[] {
|
|
783
|
+
const lines: string[] = [];
|
|
784
|
+
const count = this.getItemCount(filteredOptions);
|
|
785
|
+
const searchValue = this.searchQuery ? this.theme.fg("text", this.searchQuery) : this.theme.fg("dim", "type to filter");
|
|
786
|
+
lines.push(truncateToWidth(`${this.theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
|
|
787
|
+
|
|
788
|
+
if (this.searchQuery && filteredOptions.length === 0) {
|
|
789
|
+
lines.push(truncateToWidth(this.theme.fg("warning", "No matching options"), width, ""));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (count === 0) {
|
|
793
|
+
if (!this.searchQuery) {
|
|
794
|
+
lines.push(truncateToWidth(this.theme.fg("warning", "No options"), width, ""));
|
|
795
|
+
}
|
|
796
|
+
return lines.slice(0, this.maxVisibleRows);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
|
|
800
|
+
const optionRows = renderSingleSelectRows({
|
|
801
|
+
options: filteredOptions,
|
|
802
|
+
selectedIndex: this.selectedIndex,
|
|
803
|
+
width,
|
|
804
|
+
allowFreeform: this.allowFreeform,
|
|
805
|
+
allowComment: this.allowComment,
|
|
806
|
+
commentEnabled: this.commentEnabled,
|
|
807
|
+
maxRows,
|
|
808
|
+
hideDescriptions,
|
|
809
|
+
});
|
|
810
|
+
const optionLines = optionRows.map((row) => this.styleListLine(row.line, width, row.selected));
|
|
811
|
+
|
|
812
|
+
lines.push(...optionLines);
|
|
813
|
+
return lines.slice(0, this.maxVisibleRows);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private buildPreviewLines(width: number, filteredOptions: QuestionOption[], maxLines: number): string[] {
|
|
817
|
+
if (maxLines <= 0) return [];
|
|
818
|
+
|
|
819
|
+
const mdTheme = safeMarkdownTheme();
|
|
820
|
+
|
|
821
|
+
let md = "";
|
|
822
|
+
|
|
823
|
+
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
824
|
+
md += "## Additional context\n\n";
|
|
825
|
+
md += `Currently: **${this.commentEnabled ? "Enabled" : "Disabled"}**\n\n`;
|
|
826
|
+
md += "Turn this on when the selected option needs extra explanation before the tool submits.\n";
|
|
827
|
+
} else if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
828
|
+
md += "## Custom response\n\n";
|
|
829
|
+
md += "Open the editor to write **any** answer.\n\n";
|
|
830
|
+
md += "*Use this when none of the listed options fit.*\n";
|
|
831
|
+
if (this.searchQuery) {
|
|
832
|
+
md += `\n> Current filter: \`${this.searchQuery}\`\n`;
|
|
833
|
+
}
|
|
834
|
+
} else {
|
|
835
|
+
const selected = filteredOptions[this.selectedIndex];
|
|
836
|
+
if (!selected) {
|
|
837
|
+
md += "*No option selected*\n";
|
|
838
|
+
} else {
|
|
839
|
+
md += `## ${selected.title}\n\n`;
|
|
840
|
+
if (selected.description?.trim()) {
|
|
841
|
+
md += `${selected.description}\n`;
|
|
842
|
+
} else {
|
|
843
|
+
md += "";
|
|
844
|
+
}
|
|
845
|
+
md += `\n---\n\nPress \`Enter\` to select this option.\n`;
|
|
846
|
+
if (this.searchQuery) {
|
|
847
|
+
md += `\n> Filter: \`${this.searchQuery}\`\n`;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
let lines: string[];
|
|
853
|
+
if (mdTheme) {
|
|
854
|
+
const mdComponent = new Markdown(md.trim(), 0, 0, mdTheme);
|
|
855
|
+
lines = mdComponent.render(width);
|
|
856
|
+
} else {
|
|
857
|
+
lines = [];
|
|
858
|
+
for (const line of wrapTextWithAnsi(md.trim(), Math.max(10, width))) {
|
|
859
|
+
lines.push(truncateToWidth(line, width, ""));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") {
|
|
864
|
+
lines.pop();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (lines.length <= maxLines) return lines;
|
|
868
|
+
if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
|
|
869
|
+
|
|
870
|
+
const visibleLines = lines.slice(0, maxLines - 1);
|
|
871
|
+
visibleLines.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
|
|
872
|
+
return visibleLines;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
handleInput(data: string): void {
|
|
876
|
+
if (this.searchQuery && matchesKey(data, Key.escape)) {
|
|
877
|
+
this.setSearchQuery("");
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
882
|
+
this.onCancel?.();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
|
|
887
|
+
this.toggleComment();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const filteredOptions = this.getFilteredOptions();
|
|
892
|
+
const count = this.getItemCount(filteredOptions);
|
|
893
|
+
|
|
894
|
+
if (matchesSelectUp(data, this.keybindings) && count > 0) {
|
|
895
|
+
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
896
|
+
this.invalidate();
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (matchesSelectDown(data, this.keybindings) && count > 0) {
|
|
901
|
+
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
902
|
+
this.invalidate();
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
907
|
+
if (numMatch && filteredOptions.length > 0) {
|
|
908
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
909
|
+
if (idx >= 0 && idx < filteredOptions.length) {
|
|
910
|
+
this.selectedIndex = idx;
|
|
911
|
+
this.invalidate();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (matchesKey(data, Key.space) && count > 0 && this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
917
|
+
this.toggleComment();
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
|
|
922
|
+
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
923
|
+
this.toggleComment();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
927
|
+
this.onEnterFreeform?.();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const result = filteredOptions[this.selectedIndex]?.title;
|
|
932
|
+
if (result) this.onSubmit?.(result);
|
|
933
|
+
else this.onCancel?.();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) {
|
|
938
|
+
this.popSearchCharacter();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const printableInput = this.getPrintableInput(data);
|
|
943
|
+
if (printableInput) {
|
|
944
|
+
this.setSearchQuery(this.searchQuery + printableInput);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
render(width: number): string[] {
|
|
949
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
950
|
+
return this.cachedLines;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const filteredOptions = this.getFilteredOptions();
|
|
954
|
+
const count = this.getItemCount(filteredOptions);
|
|
955
|
+
this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
|
|
956
|
+
|
|
957
|
+
const splitPane = this.getSplitPaneWidths(width);
|
|
958
|
+
let lines: string[];
|
|
959
|
+
|
|
960
|
+
if (!splitPane) {
|
|
961
|
+
lines = this.buildListLines(width, filteredOptions);
|
|
962
|
+
} else {
|
|
963
|
+
const listLines = this.buildListLines(splitPane.left, filteredOptions, true);
|
|
964
|
+
const previewLines = this.buildPreviewLines(splitPane.right, filteredOptions, this.maxVisibleRows);
|
|
965
|
+
const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
|
|
966
|
+
const separator = this.theme.fg("dim", SINGLE_SELECT_SPLIT_PANE_SEPARATOR);
|
|
967
|
+
lines = Array.from({ length: rowCount }, (_, index) => {
|
|
968
|
+
const left = truncateToWidth(listLines[index] ?? "", splitPane.left, "", true);
|
|
969
|
+
const right = truncateToWidth(previewLines[index] ?? "", splitPane.right, "");
|
|
970
|
+
return `${left}${separator}${right}`;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
this.cachedWidth = width;
|
|
975
|
+
this.cachedLines = lines;
|
|
976
|
+
return lines;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Interactive ask UI. Uses a root Container for layout and swaps the center
|
|
982
|
+
* component between SelectList/MultiSelectList and an Editor (freeform mode).
|
|
983
|
+
*/
|
|
984
|
+
class AskComponent extends Container {
|
|
985
|
+
private question: string;
|
|
986
|
+
private context?: string;
|
|
987
|
+
private options: QuestionOption[];
|
|
988
|
+
private allowMultiple: boolean;
|
|
989
|
+
private allowFreeform: boolean;
|
|
990
|
+
private allowComment: boolean;
|
|
991
|
+
private displayMode: AskDisplayMode;
|
|
992
|
+
private tui: TUI;
|
|
993
|
+
private theme: Theme;
|
|
994
|
+
private keybindings: KeybindingsManager;
|
|
995
|
+
private shortcuts: ResolvedAskShortcuts;
|
|
996
|
+
private onDone: (result: AskUIResult | null) => void;
|
|
997
|
+
|
|
998
|
+
private mode: AskMode = "select";
|
|
999
|
+
private pendingSelections: string[] = [];
|
|
1000
|
+
private freeformDraft = "";
|
|
1001
|
+
private commentDraft = "";
|
|
1002
|
+
|
|
1003
|
+
// Static layout components
|
|
1004
|
+
private titleText: Text;
|
|
1005
|
+
private questionText: Text;
|
|
1006
|
+
private contextComponent?: Component;
|
|
1007
|
+
private modeContainer: Container;
|
|
1008
|
+
private helpText: Text;
|
|
1009
|
+
|
|
1010
|
+
// Mode components
|
|
1011
|
+
private singleSelectList?: WrappedSingleSelectList;
|
|
1012
|
+
private multiSelectList?: MultiSelectList;
|
|
1013
|
+
private editor?: Editor;
|
|
1014
|
+
|
|
1015
|
+
// Focusable - propagate to Editor for IME cursor positioning
|
|
1016
|
+
private _focused = false;
|
|
1017
|
+
get focused(): boolean {
|
|
1018
|
+
return this._focused;
|
|
1019
|
+
}
|
|
1020
|
+
set focused(value: boolean) {
|
|
1021
|
+
this._focused = value;
|
|
1022
|
+
if (this.editor && (this.mode === "freeform" || this.mode === "comment")) {
|
|
1023
|
+
(this.editor as any).focused = value;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
constructor(
|
|
1028
|
+
question: string,
|
|
1029
|
+
context: string | undefined,
|
|
1030
|
+
options: QuestionOption[],
|
|
1031
|
+
allowMultiple: boolean,
|
|
1032
|
+
allowFreeform: boolean,
|
|
1033
|
+
allowComment: boolean,
|
|
1034
|
+
displayMode: AskDisplayMode,
|
|
1035
|
+
tui: TUI,
|
|
1036
|
+
theme: Theme,
|
|
1037
|
+
keybindings: KeybindingsManager,
|
|
1038
|
+
shortcuts: ResolvedAskShortcuts,
|
|
1039
|
+
onDone: (result: AskUIResult | null) => void,
|
|
1040
|
+
) {
|
|
1041
|
+
super();
|
|
1042
|
+
|
|
1043
|
+
this.question = question;
|
|
1044
|
+
this.context = context;
|
|
1045
|
+
this.options = options;
|
|
1046
|
+
this.allowMultiple = allowMultiple;
|
|
1047
|
+
this.allowFreeform = allowFreeform;
|
|
1048
|
+
this.allowComment = allowComment;
|
|
1049
|
+
this.displayMode = displayMode;
|
|
1050
|
+
this.tui = tui;
|
|
1051
|
+
this.theme = theme;
|
|
1052
|
+
this.keybindings = keybindings;
|
|
1053
|
+
this.shortcuts = shortcuts;
|
|
1054
|
+
this.onDone = onDone;
|
|
1055
|
+
|
|
1056
|
+
// Layout skeleton
|
|
1057
|
+
this.addChild(new BoxBorderTop(
|
|
1058
|
+
(s: string) => theme.fg("accent", s),
|
|
1059
|
+
"ask_user",
|
|
1060
|
+
(s: string) => theme.fg("dim", theme.bold(s)),
|
|
1061
|
+
));
|
|
1062
|
+
this.addChild(new Spacer(1));
|
|
1063
|
+
|
|
1064
|
+
this.titleText = new Text("", 1, 0);
|
|
1065
|
+
this.addChild(this.titleText);
|
|
1066
|
+
this.addChild(new Spacer(1));
|
|
1067
|
+
|
|
1068
|
+
this.questionText = new Text("", 1, 0);
|
|
1069
|
+
this.addChild(this.questionText);
|
|
1070
|
+
|
|
1071
|
+
if (this.context) {
|
|
1072
|
+
this.addChild(new Spacer(1));
|
|
1073
|
+
const mdTheme = safeMarkdownTheme();
|
|
1074
|
+
if (mdTheme) {
|
|
1075
|
+
this.contextComponent = new Markdown("", 1, 0, mdTheme);
|
|
1076
|
+
} else {
|
|
1077
|
+
this.contextComponent = new Text("", 1, 0);
|
|
1078
|
+
}
|
|
1079
|
+
this.addChild(this.contextComponent);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
this.addChild(new Spacer(1));
|
|
1083
|
+
|
|
1084
|
+
this.modeContainer = new Container();
|
|
1085
|
+
this.addChild(this.modeContainer);
|
|
1086
|
+
|
|
1087
|
+
this.addChild(new Spacer(1));
|
|
1088
|
+
this.helpText = new Text("", 1, 0);
|
|
1089
|
+
this.addChild(this.helpText);
|
|
1090
|
+
|
|
1091
|
+
this.addChild(new Spacer(1));
|
|
1092
|
+
this.addChild(new BoxBorderBottom(
|
|
1093
|
+
(s: string) => theme.fg("accent", s),
|
|
1094
|
+
`v${ASK_USER_VERSION}`,
|
|
1095
|
+
(s: string) => theme.fg("dim", s),
|
|
1096
|
+
));
|
|
1097
|
+
|
|
1098
|
+
this.updateStaticText();
|
|
1099
|
+
this.showSelectMode();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
override invalidate(): void {
|
|
1103
|
+
super.invalidate();
|
|
1104
|
+
this.updateStaticText();
|
|
1105
|
+
this.updateHelpText();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
override render(width: number): string[] {
|
|
1109
|
+
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
1110
|
+
|
|
1111
|
+
if (this.mode === "select" && !this.allowMultiple) {
|
|
1112
|
+
const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
1113
|
+
const staticLines = this.countStaticLines(innerWidth);
|
|
1114
|
+
const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
|
|
1115
|
+
this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Render children at the inner width (excluding side border characters)
|
|
1119
|
+
const rawLines = super.render(innerWidth);
|
|
1120
|
+
|
|
1121
|
+
// First and last lines are the top/bottom box borders — pass through at full width.
|
|
1122
|
+
// All inner lines get wrapped with side borders.
|
|
1123
|
+
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
1124
|
+
const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
|
|
1125
|
+
return rawLines.map((line, index) => {
|
|
1126
|
+
if (index === 0 || index === rawLines.length - 1) {
|
|
1127
|
+
// Box top/bottom borders already rendered at innerWidth — re-render at full width
|
|
1128
|
+
if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
|
|
1129
|
+
return new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0];
|
|
1130
|
+
}
|
|
1131
|
+
const padded = truncateToWidth(line, innerWidth, "", true);
|
|
1132
|
+
return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private countWrappedLines(text: string, width: number): number {
|
|
1137
|
+
return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private countStaticLines(width: number): number {
|
|
1141
|
+
const titleLines = 1;
|
|
1142
|
+
const questionLines = this.countWrappedLines(this.question, width);
|
|
1143
|
+
const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
|
|
1144
|
+
const helpLines = 1;
|
|
1145
|
+
const borderLines = 2;
|
|
1146
|
+
const spacerLines = this.context ? 6 : 5;
|
|
1147
|
+
return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
private updateStaticText(): void {
|
|
1151
|
+
const theme = this.theme;
|
|
1152
|
+
const title = this.mode === "comment" ? "Optional comment" : "Question";
|
|
1153
|
+
this.titleText.setText(theme.fg("accent", theme.bold(title)));
|
|
1154
|
+
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
1155
|
+
if (this.contextComponent && this.context) {
|
|
1156
|
+
if (this.contextComponent instanceof Markdown) {
|
|
1157
|
+
(this.contextComponent as Markdown).setText(
|
|
1158
|
+
`**Context:**\n${this.context}`,
|
|
1159
|
+
);
|
|
1160
|
+
} else {
|
|
1161
|
+
(this.contextComponent as Text).setText(
|
|
1162
|
+
`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private updateHelpText(): void {
|
|
1169
|
+
const theme = this.theme;
|
|
1170
|
+
const overlayHint = this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled
|
|
1171
|
+
? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide")
|
|
1172
|
+
: null;
|
|
1173
|
+
const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled
|
|
1174
|
+
? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context")
|
|
1175
|
+
: null;
|
|
1176
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1177
|
+
const alternateCancelKeys = this.keybindings
|
|
1178
|
+
.getKeys("tui.select.cancel")
|
|
1179
|
+
.filter((key) => key !== "escape" && key !== "esc");
|
|
1180
|
+
const hints = [
|
|
1181
|
+
keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
|
|
1182
|
+
keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
|
|
1183
|
+
literalHint(theme, "esc", "back"),
|
|
1184
|
+
overlayHint,
|
|
1185
|
+
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
1186
|
+
]
|
|
1187
|
+
.filter((hint): hint is string => !!hint)
|
|
1188
|
+
.join(" • ");
|
|
1189
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (this.allowMultiple) {
|
|
1194
|
+
const hints = [
|
|
1195
|
+
literalHint(theme, "↑↓", "navigate"),
|
|
1196
|
+
literalHint(theme, "space", "toggle"),
|
|
1197
|
+
commentHint,
|
|
1198
|
+
overlayHint,
|
|
1199
|
+
keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
|
|
1200
|
+
keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
|
|
1201
|
+
]
|
|
1202
|
+
.filter((hint): hint is string => !!hint)
|
|
1203
|
+
.join(" • ");
|
|
1204
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
1205
|
+
} else {
|
|
1206
|
+
const alternateCancelKeys = this.keybindings
|
|
1207
|
+
.getKeys("tui.select.cancel")
|
|
1208
|
+
.filter((key) => key !== "escape" && key !== "esc");
|
|
1209
|
+
const hints = [
|
|
1210
|
+
literalHint(theme, "type", "filter"),
|
|
1211
|
+
keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
|
|
1212
|
+
literalHint(theme, "↑↓", "navigate"),
|
|
1213
|
+
commentHint,
|
|
1214
|
+
overlayHint,
|
|
1215
|
+
keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
|
|
1216
|
+
literalHint(theme, "esc", "clear/cancel"),
|
|
1217
|
+
alternateCancelKeys.length > 0
|
|
1218
|
+
? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel")
|
|
1219
|
+
: null,
|
|
1220
|
+
]
|
|
1221
|
+
.filter((hint): hint is string => !!hint)
|
|
1222
|
+
.join(" • ");
|
|
1223
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
1228
|
+
if (this.singleSelectList) return this.singleSelectList;
|
|
1229
|
+
|
|
1230
|
+
const list = new WrappedSingleSelectList(
|
|
1231
|
+
this.options,
|
|
1232
|
+
this.allowFreeform,
|
|
1233
|
+
this.allowComment,
|
|
1234
|
+
this.theme,
|
|
1235
|
+
this.keybindings,
|
|
1236
|
+
this.shortcuts.commentToggle,
|
|
1237
|
+
);
|
|
1238
|
+
list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
|
|
1239
|
+
list.onCancel = () => this.onDone(null);
|
|
1240
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
1241
|
+
|
|
1242
|
+
this.singleSelectList = list;
|
|
1243
|
+
return list;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private ensureMultiSelectList(): MultiSelectList {
|
|
1247
|
+
if (this.multiSelectList) return this.multiSelectList;
|
|
1248
|
+
|
|
1249
|
+
const list = new MultiSelectList(
|
|
1250
|
+
this.options,
|
|
1251
|
+
this.allowFreeform,
|
|
1252
|
+
this.allowComment,
|
|
1253
|
+
this.theme,
|
|
1254
|
+
this.keybindings,
|
|
1255
|
+
this.shortcuts.commentToggle,
|
|
1256
|
+
);
|
|
1257
|
+
list.onCancel = () => this.onDone(null);
|
|
1258
|
+
list.onSubmit = (result) => this.handleSelectionSubmit(result, list.isCommentEnabled());
|
|
1259
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
1260
|
+
|
|
1261
|
+
this.multiSelectList = list;
|
|
1262
|
+
return list;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
private ensureEditor(): Editor {
|
|
1266
|
+
if (this.editor) return this.editor;
|
|
1267
|
+
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
1268
|
+
editor.disableSubmit = false;
|
|
1269
|
+
editor.onSubmit = (text: string) => {
|
|
1270
|
+
this.handleEditorSubmit(text);
|
|
1271
|
+
};
|
|
1272
|
+
this.editor = editor;
|
|
1273
|
+
return editor;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private saveEditorDraft(): void {
|
|
1277
|
+
if (!this.editor) return;
|
|
1278
|
+
const getText = (this.editor as any).getText;
|
|
1279
|
+
if (typeof getText !== "function") return;
|
|
1280
|
+
|
|
1281
|
+
const currentText = String(getText.call(this.editor) ?? "");
|
|
1282
|
+
if (this.mode === "freeform") {
|
|
1283
|
+
this.freeformDraft = currentText;
|
|
1284
|
+
} else if (this.mode === "comment") {
|
|
1285
|
+
this.commentDraft = currentText;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
private setEditorText(text: string): void {
|
|
1290
|
+
const editor = this.ensureEditor();
|
|
1291
|
+
const setText = (editor as any).setText;
|
|
1292
|
+
if (typeof setText === "function") {
|
|
1293
|
+
setText.call(editor, text);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
private handleSelectionSubmit(selections: string[], wantsComment: boolean): void {
|
|
1298
|
+
if (this.allowComment && wantsComment) {
|
|
1299
|
+
this.pendingSelections = selections;
|
|
1300
|
+
this.commentDraft = "";
|
|
1301
|
+
this.showCommentMode();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
this.onDone(createSelectionResponse(selections));
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
private handleEditorSubmit(text: string): void {
|
|
1309
|
+
if (this.mode === "freeform") {
|
|
1310
|
+
this.onDone(createFreeformResponse(text));
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (this.mode === "comment") {
|
|
1315
|
+
this.commentDraft = text;
|
|
1316
|
+
this.onDone(createSelectionResponse(this.pendingSelections, text));
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
private showSelectMode(): void {
|
|
1321
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1322
|
+
this.saveEditorDraft();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
this.mode = "select";
|
|
1326
|
+
this.pendingSelections = [];
|
|
1327
|
+
this.modeContainer.clear();
|
|
1328
|
+
|
|
1329
|
+
if (this.allowMultiple) {
|
|
1330
|
+
this.modeContainer.addChild(this.ensureMultiSelectList());
|
|
1331
|
+
} else {
|
|
1332
|
+
this.modeContainer.addChild(this.ensureSingleSelectList());
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
this.updateHelpText();
|
|
1336
|
+
this.invalidate();
|
|
1337
|
+
this.tui.requestRender();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
private showFreeformMode(): void {
|
|
1341
|
+
if (this.mode === "comment") {
|
|
1342
|
+
this.saveEditorDraft();
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
this.mode = "freeform";
|
|
1346
|
+
this.modeContainer.clear();
|
|
1347
|
+
|
|
1348
|
+
const editor = this.ensureEditor();
|
|
1349
|
+
this.setEditorText(this.freeformDraft);
|
|
1350
|
+
(editor as any).focused = this._focused;
|
|
1351
|
+
|
|
1352
|
+
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
|
|
1353
|
+
this.modeContainer.addChild(new Spacer(1));
|
|
1354
|
+
this.modeContainer.addChild(editor);
|
|
1355
|
+
|
|
1356
|
+
this.updateHelpText();
|
|
1357
|
+
this.invalidate();
|
|
1358
|
+
this.tui.requestRender();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
private showCommentMode(): void {
|
|
1362
|
+
if (this.mode === "freeform") {
|
|
1363
|
+
this.saveEditorDraft();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
this.mode = "comment";
|
|
1367
|
+
this.modeContainer.clear();
|
|
1368
|
+
|
|
1369
|
+
const editor = this.ensureEditor();
|
|
1370
|
+
this.setEditorText(this.commentDraft);
|
|
1371
|
+
(editor as any).focused = this._focused;
|
|
1372
|
+
|
|
1373
|
+
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
1374
|
+
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0));
|
|
1375
|
+
this.modeContainer.addChild(new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0));
|
|
1376
|
+
this.modeContainer.addChild(new Spacer(1));
|
|
1377
|
+
this.modeContainer.addChild(editor);
|
|
1378
|
+
|
|
1379
|
+
this.updateHelpText();
|
|
1380
|
+
this.invalidate();
|
|
1381
|
+
this.tui.requestRender();
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
handleInput(data: string): void {
|
|
1385
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1386
|
+
if (matchesKey(data, Key.escape)) {
|
|
1387
|
+
this.showSelectMode();
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
1392
|
+
this.onDone(null);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
this.ensureEditor().handleInput(data);
|
|
1397
|
+
this.tui.requestRender();
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (this.allowMultiple) {
|
|
1402
|
+
this.ensureMultiSelectList().handleInput?.(data);
|
|
1403
|
+
this.tui.requestRender();
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
this.ensureSingleSelectList().handleInput?.(data);
|
|
1408
|
+
this.tui.requestRender();
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* RPC/headless fallback: use dialog methods (select/input) instead of the rich TUI overlay.
|
|
1414
|
+
* ctx.ui.custom() returns undefined in RPC mode, so we degrade gracefully.
|
|
1415
|
+
*/
|
|
1416
|
+
async function askViaDialogs(
|
|
1417
|
+
ui: { select: Function; input: Function },
|
|
1418
|
+
question: string,
|
|
1419
|
+
context: string | undefined,
|
|
1420
|
+
options: QuestionOption[],
|
|
1421
|
+
allowMultiple: boolean,
|
|
1422
|
+
allowFreeform: boolean,
|
|
1423
|
+
allowComment: boolean,
|
|
1424
|
+
timeout?: number,
|
|
1425
|
+
): Promise<AskUIResult | null> {
|
|
1426
|
+
const dialogOpts = timeout ? { timeout } : undefined;
|
|
1427
|
+
const prompt = context ? `${question}\n\nContext:\n${context}` : question;
|
|
1428
|
+
|
|
1429
|
+
if (allowMultiple) {
|
|
1430
|
+
const optionList = formatOptionsForMessage(options);
|
|
1431
|
+
const rawSelections = await ui.input(
|
|
1432
|
+
`${prompt}\n\nOptions (select one or more):\n${optionList}`,
|
|
1433
|
+
"Type your selection(s)...",
|
|
1434
|
+
dialogOpts,
|
|
1435
|
+
) as string | undefined;
|
|
1436
|
+
if (isCancelledInput(rawSelections)) return null;
|
|
1437
|
+
|
|
1438
|
+
const selections = parseDialogSelections(rawSelections);
|
|
1439
|
+
if (selections.length === 0) return null;
|
|
1440
|
+
|
|
1441
|
+
if (!allowComment) {
|
|
1442
|
+
return createSelectionResponse(selections);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const comment = await ui.input(
|
|
1446
|
+
buildCommentPrompt(prompt, selections),
|
|
1447
|
+
"Optional comment (press Enter to skip)...",
|
|
1448
|
+
dialogOpts,
|
|
1449
|
+
) as string | undefined;
|
|
1450
|
+
return createSelectionResponse(selections, comment);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const selectOptions = options.map((o) => o.title);
|
|
1454
|
+
if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
|
|
1455
|
+
|
|
1456
|
+
const selected = await ui.select(prompt, selectOptions, dialogOpts) as string | undefined;
|
|
1457
|
+
if (isCancelledInput(selected)) return null;
|
|
1458
|
+
|
|
1459
|
+
if (selected === FREEFORM_SENTINEL) {
|
|
1460
|
+
const answer = await ui.input(prompt, "Type your answer...", dialogOpts) as string | undefined;
|
|
1461
|
+
if (isCancelledInput(answer)) return null;
|
|
1462
|
+
return createFreeformResponse(answer);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (!allowComment) {
|
|
1466
|
+
return createSelectionResponse([selected]);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const comment = await ui.input(
|
|
1470
|
+
buildCommentPrompt(prompt, [selected]),
|
|
1471
|
+
"Optional comment (press Enter to skip)...",
|
|
1472
|
+
dialogOpts,
|
|
1473
|
+
) as string | undefined;
|
|
1474
|
+
return createSelectionResponse([selected], comment);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
export async function askUser(
|
|
1478
|
+
ctx: { hasUI: boolean; ui: any },
|
|
1479
|
+
opts: {
|
|
1480
|
+
question: string;
|
|
1481
|
+
context?: string;
|
|
1482
|
+
options?: AskOptionInput[];
|
|
1483
|
+
allowMultiple?: boolean;
|
|
1484
|
+
allowFreeform?: boolean;
|
|
1485
|
+
allowComment?: boolean;
|
|
1486
|
+
timeout?: number;
|
|
1487
|
+
signal?: AbortSignal;
|
|
1488
|
+
overlay?: boolean;
|
|
1489
|
+
displayMode?: AskDisplayMode;
|
|
1490
|
+
overlayToggleKey?: string | null;
|
|
1491
|
+
commentToggleKey?: string | null;
|
|
1492
|
+
},
|
|
1493
|
+
): Promise<AskResponse | null> {
|
|
1494
|
+
const {
|
|
1495
|
+
question,
|
|
1496
|
+
context,
|
|
1497
|
+
options: rawOptions = [],
|
|
1498
|
+
allowMultiple = false,
|
|
1499
|
+
allowFreeform = true,
|
|
1500
|
+
allowComment = false,
|
|
1501
|
+
timeout,
|
|
1502
|
+
signal,
|
|
1503
|
+
overlay,
|
|
1504
|
+
displayMode,
|
|
1505
|
+
overlayToggleKey,
|
|
1506
|
+
commentToggleKey,
|
|
1507
|
+
} = opts;
|
|
1508
|
+
|
|
1509
|
+
const requestedMode = displayMode ?? (overlay === undefined ? undefined : overlay ? "overlay" : "inline");
|
|
1510
|
+
const envMode = process.env.PI_ASK_USER_DISPLAY_MODE;
|
|
1511
|
+
const envDisplayMode: AskDisplayMode | undefined =
|
|
1512
|
+
envMode === "overlay" || envMode === "inline" ? envMode : undefined;
|
|
1513
|
+
const effectiveDisplayMode: AskDisplayMode = requestedMode ?? envDisplayMode ?? "inline";
|
|
1514
|
+
|
|
1515
|
+
const shortcuts: ResolvedAskShortcuts = {
|
|
1516
|
+
overlayToggle: resolveShortcut(
|
|
1517
|
+
overlayToggleKey,
|
|
1518
|
+
process.env.PI_ASK_USER_OVERLAY_TOGGLE_KEY,
|
|
1519
|
+
DEFAULT_OVERLAY_TOGGLE_KEY,
|
|
1520
|
+
),
|
|
1521
|
+
commentToggle: resolveShortcut(
|
|
1522
|
+
commentToggleKey,
|
|
1523
|
+
process.env.PI_ASK_USER_COMMENT_TOGGLE_KEY,
|
|
1524
|
+
DEFAULT_COMMENT_TOGGLE_KEY,
|
|
1525
|
+
),
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
const options = normalizeOptions(rawOptions);
|
|
1529
|
+
const normalizedContext = context?.trim() || undefined;
|
|
1530
|
+
|
|
1531
|
+
if (!ctx.hasUI || !ctx.ui) return null;
|
|
1532
|
+
|
|
1533
|
+
if (options.length === 0) {
|
|
1534
|
+
const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
|
|
1535
|
+
const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
|
|
1536
|
+
return createFreeformResponse(answer);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
1540
|
+
let removeOverlayInputListener: (() => void) | undefined;
|
|
1541
|
+
let hasAnnouncedHide = false;
|
|
1542
|
+
|
|
1543
|
+
ctx.ui.setWorkingMessage?.("Waiting for user to answer…");
|
|
1544
|
+
try {
|
|
1545
|
+
const customFactory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskUIResult | null) => void) => {
|
|
1546
|
+
if (signal) {
|
|
1547
|
+
const onAbort = () => done(null);
|
|
1548
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1549
|
+
}
|
|
1550
|
+
if (timeout && timeout > 0) {
|
|
1551
|
+
setTimeout(() => done(null), timeout);
|
|
1552
|
+
}
|
|
1553
|
+
return new AskComponent(
|
|
1554
|
+
question,
|
|
1555
|
+
normalizedContext,
|
|
1556
|
+
options,
|
|
1557
|
+
allowMultiple,
|
|
1558
|
+
allowFreeform,
|
|
1559
|
+
allowComment,
|
|
1560
|
+
effectiveDisplayMode,
|
|
1561
|
+
tui,
|
|
1562
|
+
theme,
|
|
1563
|
+
keybindings,
|
|
1564
|
+
shortcuts,
|
|
1565
|
+
done,
|
|
1566
|
+
);
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
const overlayToggle = shortcuts.overlayToggle;
|
|
1570
|
+
if (
|
|
1571
|
+
effectiveDisplayMode === "overlay"
|
|
1572
|
+
&& !overlayToggle.disabled
|
|
1573
|
+
&& typeof ctx.ui.onTerminalInput === "function"
|
|
1574
|
+
) {
|
|
1575
|
+
removeOverlayInputListener = ctx.ui.onTerminalInput((data: string) => {
|
|
1576
|
+
if (!overlayToggle.matches(data) || !overlayHandle) return undefined;
|
|
1577
|
+
const nextHidden = !overlayHandle.isHidden();
|
|
1578
|
+
overlayHandle.setHidden(nextHidden);
|
|
1579
|
+
if (nextHidden && !hasAnnouncedHide) {
|
|
1580
|
+
hasAnnouncedHide = true;
|
|
1581
|
+
ctx.ui.notify?.(`ask_user hidden — press ${overlayToggle.spec} to reopen`, "info");
|
|
1582
|
+
}
|
|
1583
|
+
return { consume: true };
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const customResult = await ctx.ui.custom(
|
|
1588
|
+
customFactory,
|
|
1589
|
+
buildCustomUIOptions(effectiveDisplayMode, (handle) => {
|
|
1590
|
+
overlayHandle = handle;
|
|
1591
|
+
}),
|
|
1592
|
+
) as AskUIResult | null | undefined;
|
|
1593
|
+
|
|
1594
|
+
if (customResult !== undefined) return customResult;
|
|
1595
|
+
return askViaDialogs(ctx.ui, question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, timeout);
|
|
1596
|
+
} finally {
|
|
1597
|
+
removeOverlayInputListener?.();
|
|
1598
|
+
ctx.ui.setWorkingMessage?.();
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
export default function(pi: ExtensionAPI) {
|
|
1603
|
+
pi.registerTool({
|
|
1604
|
+
name: "ask_user",
|
|
1605
|
+
label: "Ask User",
|
|
1606
|
+
description:
|
|
1607
|
+
"Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Before calling, gather context with tools (read/web/ref) and pass a short summary via the context field.",
|
|
1608
|
+
promptSnippet:
|
|
1609
|
+
"Ask the user one focused question with optional multiple-choice answers to gather information interactively",
|
|
1610
|
+
promptGuidelines: [
|
|
1611
|
+
"Before calling ask_user, gather context with tools (read/web/ref) and pass a short summary via the context field.",
|
|
1612
|
+
"Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
|
|
1613
|
+
"Ask exactly one focused question per ask_user call.",
|
|
1614
|
+
"Do not combine multiple numbered, multipart, or unrelated questions into one ask_user prompt.",
|
|
1615
|
+
],
|
|
1616
|
+
// Block other tool calls in the same assistant turn until the user answers,
|
|
1617
|
+
// so the model can't batch ask_user with bash/edit/write and let those run
|
|
1618
|
+
// (potentially with side effects) before the user sees the prompt.
|
|
1619
|
+
executionMode: "sequential",
|
|
1620
|
+
parameters: Type.Object({
|
|
1621
|
+
question: Type.String({ description: "The question to ask the user" }),
|
|
1622
|
+
context: Type.Optional(
|
|
1623
|
+
Type.String({
|
|
1624
|
+
description: "Relevant context to show before the question (summary of findings)",
|
|
1625
|
+
}),
|
|
1626
|
+
),
|
|
1627
|
+
options: Type.Optional(
|
|
1628
|
+
Type.Array(
|
|
1629
|
+
Type.Union([
|
|
1630
|
+
Type.String({ description: "Short title for this option" }),
|
|
1631
|
+
Type.Object({
|
|
1632
|
+
title: Type.String({ description: "Short title for this option" }),
|
|
1633
|
+
description: Type.Optional(
|
|
1634
|
+
Type.String({ description: "Longer description explaining this option" }),
|
|
1635
|
+
),
|
|
1636
|
+
}),
|
|
1637
|
+
]),
|
|
1638
|
+
{ description: "List of options for the user to choose from" },
|
|
1639
|
+
),
|
|
1640
|
+
),
|
|
1641
|
+
allowMultiple: Type.Optional(
|
|
1642
|
+
Type.Boolean({ description: "Allow selecting multiple options. Default: false" }),
|
|
1643
|
+
),
|
|
1644
|
+
allowFreeform: Type.Optional(
|
|
1645
|
+
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
1646
|
+
),
|
|
1647
|
+
allowComment: Type.Optional(
|
|
1648
|
+
Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }),
|
|
1649
|
+
),
|
|
1650
|
+
displayMode: Type.Optional(
|
|
1651
|
+
StringEnum(["overlay", "inline"] as const, {
|
|
1652
|
+
description: "UI rendering mode. 'overlay' shows a centered modal, 'inline' renders in-place. Default: PI_ASK_USER_DISPLAY_MODE env var if set, otherwise 'inline'. Omit to respect the user's configured preference.",
|
|
1653
|
+
}),
|
|
1654
|
+
),
|
|
1655
|
+
overlayToggleKey: Type.Optional(
|
|
1656
|
+
Type.String({
|
|
1657
|
+
description:
|
|
1658
|
+
"Shortcut for hiding/showing the overlay popup (overlay mode only), e.g. 'alt+o' or 'ctrl+shift+h'. Pass 'off' to disable. Default: PI_ASK_USER_OVERLAY_TOGGLE_KEY env var if set, otherwise 'alt+o'.",
|
|
1659
|
+
}),
|
|
1660
|
+
),
|
|
1661
|
+
commentToggleKey: Type.Optional(
|
|
1662
|
+
Type.String({
|
|
1663
|
+
description:
|
|
1664
|
+
"Shortcut for toggling the optional comment/extra-context row when allowComment is true, e.g. 'ctrl+g'. Pass 'off' to disable. Default: PI_ASK_USER_COMMENT_TOGGLE_KEY env var if set, otherwise 'ctrl+g'.",
|
|
1665
|
+
}),
|
|
1666
|
+
),
|
|
1667
|
+
timeout: Type.Optional(
|
|
1668
|
+
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
1669
|
+
),
|
|
1670
|
+
}),
|
|
1671
|
+
|
|
1672
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1673
|
+
if (signal?.aborted) {
|
|
1674
|
+
return {
|
|
1675
|
+
content: [{ type: "text" as const, text: "Cancelled" }],
|
|
1676
|
+
details: { question: params.question, options: [], response: null, cancelled: true } as AskToolDetails,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const {
|
|
1681
|
+
question,
|
|
1682
|
+
context,
|
|
1683
|
+
options: rawOptions = [],
|
|
1684
|
+
allowMultiple = false,
|
|
1685
|
+
allowFreeform = true,
|
|
1686
|
+
allowComment = false,
|
|
1687
|
+
displayMode,
|
|
1688
|
+
overlayToggleKey,
|
|
1689
|
+
commentToggleKey,
|
|
1690
|
+
timeout,
|
|
1691
|
+
} = params as AskParams;
|
|
1692
|
+
|
|
1693
|
+
const options = normalizeOptions(rawOptions);
|
|
1694
|
+
const normalizedContext = context?.trim() || undefined;
|
|
1695
|
+
|
|
1696
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
1697
|
+
const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
|
|
1698
|
+
const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
|
|
1699
|
+
const commentHint = allowComment ? "\n\nAfter choosing an option, you may add an optional comment." : "";
|
|
1700
|
+
const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
|
|
1701
|
+
return {
|
|
1702
|
+
content: [{ type: "text" as const, text: `Ask requires interactive mode. Please answer:\n\n${question}${contextText}${optionText}${freeformHint}${commentHint}` }],
|
|
1703
|
+
isError: true,
|
|
1704
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: true } as AskToolDetails,
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (options.length > 0) {
|
|
1709
|
+
onUpdate?.({
|
|
1710
|
+
content: [{ type: "text" as const, text: "Waiting for user input..." }],
|
|
1711
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: false },
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
let result: AskResponse | null;
|
|
1716
|
+
try {
|
|
1717
|
+
result = await askUser(ctx, {
|
|
1718
|
+
question,
|
|
1719
|
+
context: normalizedContext,
|
|
1720
|
+
options,
|
|
1721
|
+
allowMultiple,
|
|
1722
|
+
allowFreeform,
|
|
1723
|
+
allowComment,
|
|
1724
|
+
displayMode,
|
|
1725
|
+
overlayToggleKey,
|
|
1726
|
+
commentToggleKey,
|
|
1727
|
+
timeout,
|
|
1728
|
+
signal,
|
|
1729
|
+
});
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
const message =
|
|
1732
|
+
error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
|
|
1733
|
+
return {
|
|
1734
|
+
content: [{ type: "text" as const, text: `Ask tool failed: ${message}` }],
|
|
1735
|
+
isError: true,
|
|
1736
|
+
details: { error: message },
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (result === null) {
|
|
1741
|
+
pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
|
|
1742
|
+
return {
|
|
1743
|
+
content: [{ type: "text" as const, text: "User cancelled the question" }],
|
|
1744
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: true } as AskToolDetails,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
pi.events.emit("ask:answered", {
|
|
1749
|
+
question,
|
|
1750
|
+
context: normalizedContext,
|
|
1751
|
+
response: result,
|
|
1752
|
+
});
|
|
1753
|
+
return {
|
|
1754
|
+
content: [{ type: "text" as const, text: `User answered: ${formatResponseSummary(result)}` }],
|
|
1755
|
+
details: {
|
|
1756
|
+
question,
|
|
1757
|
+
context: normalizedContext,
|
|
1758
|
+
options,
|
|
1759
|
+
response: result,
|
|
1760
|
+
cancelled: false,
|
|
1761
|
+
} as AskToolDetails,
|
|
1762
|
+
};
|
|
1763
|
+
},
|
|
1764
|
+
|
|
1765
|
+
renderCall(args, theme) {
|
|
1766
|
+
const question = (args.question as string) || "";
|
|
1767
|
+
const rawOptions = Array.isArray(args.options) ? args.options : [];
|
|
1768
|
+
let text = theme.fg("toolTitle", theme.bold("ask_user "));
|
|
1769
|
+
text += theme.fg("muted", question);
|
|
1770
|
+
if (rawOptions.length > 0) {
|
|
1771
|
+
const labels = rawOptions.map((o: unknown) =>
|
|
1772
|
+
typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
|
|
1773
|
+
);
|
|
1774
|
+
text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
|
|
1775
|
+
}
|
|
1776
|
+
if (args.allowMultiple) {
|
|
1777
|
+
text += theme.fg("dim", " [multi-select]");
|
|
1778
|
+
}
|
|
1779
|
+
if (args.allowComment) {
|
|
1780
|
+
text += theme.fg("dim", " [optional comment]");
|
|
1781
|
+
}
|
|
1782
|
+
return new Text(text, 0, 0);
|
|
1783
|
+
},
|
|
1784
|
+
|
|
1785
|
+
renderResult(result, options, theme) {
|
|
1786
|
+
const details = result.details as (AskToolDetails & { error?: string }) | undefined;
|
|
1787
|
+
|
|
1788
|
+
if (details?.error) {
|
|
1789
|
+
return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
if (options.isPartial) {
|
|
1793
|
+
const waitingText = result.content
|
|
1794
|
+
?.filter((part) => part?.type === "text")
|
|
1795
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
1796
|
+
.join("\n")
|
|
1797
|
+
.trim() || "Waiting for user input...";
|
|
1798
|
+
return new Text(theme.fg("muted", waitingText), 0, 0);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (!details || details.cancelled || !details.response) {
|
|
1802
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
const response = details.response;
|
|
1806
|
+
let text = theme.fg("success", "✓ ");
|
|
1807
|
+
if (response.kind === "freeform") {
|
|
1808
|
+
text += theme.fg("muted", "(wrote) ");
|
|
1809
|
+
}
|
|
1810
|
+
text += theme.fg("accent", formatResponseSummary(response));
|
|
1811
|
+
|
|
1812
|
+
if (options.expanded) {
|
|
1813
|
+
text += "\n" + theme.fg("dim", `Q: ${details.question}`);
|
|
1814
|
+
if (details.context) {
|
|
1815
|
+
text += "\n" + theme.fg("dim", details.context);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
if (isSelectionResponse(response) && details.options.length > 0) {
|
|
1819
|
+
const selectedTitles = new Set(response.selections);
|
|
1820
|
+
text += "\n" + theme.fg("dim", "Options:");
|
|
1821
|
+
for (const opt of details.options) {
|
|
1822
|
+
const desc = opt.description ? ` — ${opt.description}` : "";
|
|
1823
|
+
const marker = selectedTitles.has(opt.title) ? theme.fg("success", "●") : theme.fg("dim", "○");
|
|
1824
|
+
text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
|
|
1825
|
+
}
|
|
1826
|
+
if (response.comment) {
|
|
1827
|
+
text += `\n${theme.fg("dim", "Comment:")} ${theme.fg("dim", response.comment)}`;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
return new Text(text, 0, 0);
|
|
1833
|
+
},
|
|
1834
|
+
});
|
|
1835
|
+
}
|