@pi-unipi/unipi 3.0.0-alpha.2 → 3.0.0-alpha.21
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/CHANGELOG.md +292 -0
- package/README.md +8 -11
- package/docs/prefix-cache-architecture.md +14 -0
- package/package.json +22 -24
- package/packages/ask-user/README.md +55 -107
- package/packages/ask-user/ask-ui.ts +306 -732
- package/packages/ask-user/clipboard.ts +50 -0
- package/packages/ask-user/config.ts +74 -78
- package/packages/ask-user/package.json +2 -2
- package/packages/ask-user/questions.ts +151 -0
- package/packages/ask-user/skills/ask-user/SKILL.md +59 -133
- package/packages/ask-user/tools.ts +198 -432
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +38 -43
- package/packages/autocomplete/src/sorting.ts +8 -0
- package/packages/background-tasks/package.json +2 -2
- package/packages/background-tasks/src/config.ts +0 -73
- package/packages/background-tasks/src/index.ts +20 -16
- package/packages/background-tasks/src/registry.ts +1 -171
- package/packages/background-tasks/src/types.ts +0 -32
- package/packages/btw/README.md +13 -56
- package/packages/btw/extensions/btw.ts +403 -1796
- package/packages/btw/package.json +5 -2
- package/packages/btw/skills/btw/SKILL.md +7 -143
- package/packages/compactor/README.md +56 -287
- package/packages/compactor/package.json +5 -9
- package/packages/compactor/skills/compactor/SKILL.md +13 -20
- package/packages/compactor/src/card.ts +90 -0
- package/packages/compactor/src/commands/index.ts +73 -130
- package/packages/compactor/src/compaction/compact-args.ts +5 -3
- package/packages/compactor/src/compaction/filter-noise.ts +1 -1
- package/packages/compactor/src/compaction/hooks.ts +487 -311
- package/packages/compactor/src/compaction/source.ts +178 -0
- package/packages/compactor/src/compaction/summarize.ts +586 -52
- package/packages/compactor/src/config/manager.ts +130 -127
- package/packages/compactor/src/config/schema.ts +32 -48
- package/packages/compactor/src/index.ts +40 -431
- package/packages/compactor/src/info-screen.ts +44 -190
- package/packages/compactor/src/session/recall-blocks.ts +1 -1
- package/packages/compactor/src/stats.ts +76 -0
- package/packages/compactor/src/tools/context-budget.ts +17 -34
- package/packages/compactor/src/tools/ctx-doctor.ts +45 -36
- package/packages/compactor/src/tools/register.ts +29 -269
- package/packages/compactor/src/types.ts +47 -186
- package/packages/core/README.md +25 -0
- package/packages/core/child-agent.ts +1 -0
- package/packages/core/command-echo.ts +63 -0
- package/packages/core/compaction-context.ts +41 -0
- package/packages/core/constants.ts +8 -38
- package/packages/core/events.ts +0 -53
- package/packages/core/fusion-status.ts +4 -0
- package/packages/core/global.d.ts +10 -0
- package/packages/core/harness-messages.ts +392 -0
- package/packages/core/index.ts +14 -0
- package/packages/core/kanboard-status.ts +49 -0
- package/packages/core/long-horizon-owner-status.ts +46 -0
- package/packages/core/model-cache.ts +81 -7
- package/packages/core/package.json +6 -3
- package/packages/core/src/attach/detect.ts +163 -0
- package/packages/core/src/child-agent/child-args.ts +31 -0
- package/packages/core/src/child-agent/delivery.ts +41 -0
- package/packages/core/src/child-agent/index.ts +5 -0
- package/packages/core/src/child-agent/readers.ts +203 -0
- package/packages/core/src/child-agent/runtime.ts +584 -0
- package/packages/core/src/evidence.ts +78 -0
- package/packages/core/src/hints/browser.ts +278 -0
- package/packages/core/src/hints/crab-data.ts +19 -0
- package/packages/core/src/hints/index.ts +865 -0
- package/packages/core/src/hints/lines.ts +607 -0
- package/packages/core/src/hints/store.ts +120 -0
- package/packages/core/src/jev/chatter.ts +24 -0
- package/packages/core/src/jev/settings.ts +177 -14
- package/packages/core/src/package-colors.ts +2 -0
- package/packages/core/src/settings/catalog.ts +39 -15
- package/packages/core/src/settings/engine.ts +48 -2
- package/packages/core/src/settings/hub.ts +83 -20
- package/packages/core/src/settings/open.ts +49 -0
- package/packages/core/src/settings/schema.ts +27 -7
- package/packages/core/src/tui/hub-kit.ts +18 -5
- package/packages/core/src/tui/kit.ts +198 -0
- package/packages/core/src/tui/progress.ts +73 -0
- package/packages/core/src/turn/arbiter.ts +290 -0
- package/packages/footer/README.md +4 -6
- package/packages/footer/package.json +3 -3
- package/packages/footer/src/glance-editor.ts +19 -11
- package/packages/footer/src/help.ts +1 -1
- package/packages/footer/src/index.ts +36 -3
- package/packages/footer/src/presets.ts +3 -4
- package/packages/footer/src/rendering/icons.ts +0 -20
- package/packages/footer/src/segments/compactor.ts +52 -142
- package/packages/footer/src/segments/status-ext.ts +0 -1
- package/packages/fusion/README.md +14 -4
- package/packages/fusion/package.json +4 -3
- package/packages/fusion/src/child-args.ts +1 -0
- package/packages/fusion/src/index.ts +161 -8
- package/packages/fusion/src/pi-settings.ts +57 -0
- package/packages/fusion/src/picker.ts +186 -51
- package/packages/fusion/src/preset.ts +56 -2
- package/packages/fusion/src/prompts.ts +1 -1
- package/packages/fusion/src/sidekick-runtime.ts +1 -420
- package/packages/fusion/src/tools.ts +107 -128
- package/packages/fusion/src/transcript.ts +27 -85
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/README.md +18 -30
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/input-shortcuts/src/burst.ts +88 -0
- package/packages/input-shortcuts/src/chord-overlay.ts +20 -70
- package/packages/input-shortcuts/src/cleared-input.ts +97 -0
- package/packages/input-shortcuts/src/index.ts +99 -193
- package/packages/input-shortcuts/src/last-response.ts +40 -0
- package/packages/input-shortcuts/src/registers.ts +8 -23
- package/packages/input-shortcuts/src/types.ts +4 -16
- package/packages/input-shortcuts/src/undo-redo.ts +59 -47
- package/packages/kanboard/README.md +51 -47
- package/packages/kanboard/index.ts +293 -55
- package/packages/kanboard/package.json +8 -8
- package/packages/kanboard/skills/kanboard/SKILL.md +61 -27
- package/packages/kanboard/src/badges.ts +93 -0
- package/packages/kanboard/src/bin.ts +4 -2
- package/packages/kanboard/src/commands.ts +218 -84
- package/packages/kanboard/src/debug.ts +20 -0
- package/packages/kanboard/src/guard.ts +267 -109
- package/packages/kanboard/src/monitor.ts +330 -0
- package/packages/kanboard/src/notice-buffer.ts +74 -0
- package/packages/kanboard/src/progress.ts +27 -0
- package/packages/kanboard/src/reminders.ts +234 -0
- package/packages/kanboard/src/settings.ts +69 -15
- package/packages/kanboard/src/shapes.ts +5 -0
- package/packages/long-horizon/index.ts +190 -13
- package/packages/long-horizon/package.json +3 -3
- package/packages/long-horizon/src/commands.ts +103 -7
- package/packages/long-horizon/src/compaction-brief.ts +35 -0
- package/packages/long-horizon/src/engine/continuation.ts +84 -15
- package/packages/long-horizon/src/engine/goal-state.ts +64 -13
- package/packages/long-horizon/src/engine/nudge-stash.ts +71 -0
- package/packages/long-horizon/src/engine/ralph.ts +3 -2
- package/packages/long-horizon/src/engine/runaway.ts +68 -11
- package/packages/long-horizon/src/engine/verifier.ts +19 -0
- package/packages/long-horizon/src/gate.ts +37 -12
- package/packages/long-horizon/src/judge/resolve.ts +1 -0
- package/packages/long-horizon/src/owner.ts +35 -2
- package/packages/long-horizon/src/progress.ts +87 -0
- package/packages/long-horizon/src/prompts/goal.ts +1 -1
- package/packages/long-horizon/src/runtime.ts +172 -18
- package/packages/long-horizon/src/settings.ts +54 -39
- package/packages/long-horizon/src/tools/goal.ts +6 -0
- package/packages/long-horizon/src/tools/graph.ts +1 -1
- package/packages/long-horizon/src/tools/swarm.ts +3 -3
- package/packages/mcp/package.json +2 -2
- package/packages/memory/commands.ts +7 -2
- package/packages/memory/index.ts +210 -32
- package/packages/memory/package.json +3 -3
- package/packages/memory/save-session.ts +264 -0
- package/packages/memory/settings.ts +17 -0
- package/packages/memory/tools.ts +253 -203
- package/packages/notify/package.json +2 -2
- package/packages/skill-registry/README.md +53 -16
- package/packages/skill-registry/index.ts +230 -0
- package/packages/skill-registry/package.json +19 -8
- package/packages/skill-registry/src/editor.ts +206 -0
- package/packages/skill-registry/src/judge.ts +289 -0
- package/packages/skill-registry/src/registry.ts +87 -0
- package/packages/skill-registry/src/settings.ts +259 -0
- package/packages/skill-registry/src/vault.ts +80 -0
- package/packages/subagents/README.md +39 -119
- package/packages/subagents/package.json +6 -13
- package/packages/subagents/skills/subagents/SKILL.md +18 -42
- package/packages/subagents/src/agents.ts +213 -0
- package/packages/subagents/src/cards.ts +182 -0
- package/packages/subagents/src/index.ts +609 -1084
- package/packages/subagents/src/manager.ts +429 -0
- package/packages/subagents/src/profiles.ts +130 -0
- package/packages/subagents/src/transcript.ts +154 -0
- package/packages/subagents/src/ui.ts +389 -0
- package/packages/unipi/bundled.js +39756 -45569
- package/packages/unipi/index.ts +55 -10
- package/packages/updater/package.json +2 -2
- package/packages/utility/README.md +76 -69
- package/packages/utility/package.json +2 -2
- package/packages/utility/skills/image/SKILL.md +37 -0
- package/packages/utility/skills/utility/SKILL.md +17 -60
- package/packages/utility/src/answer/extract.ts +128 -0
- package/packages/utility/src/answer/index.ts +213 -0
- package/packages/utility/src/answer/reply.ts +133 -0
- package/packages/utility/src/answer/web.ts +167 -0
- package/packages/utility/src/attach/index.ts +205 -0
- package/packages/utility/src/commands.ts +38 -142
- package/packages/utility/src/diagnostics/engine.ts +50 -5
- package/packages/utility/src/image/index.ts +33 -0
- package/packages/utility/src/image/models.ts +221 -0
- package/packages/{image/src → utility/src/image}/recognize.ts +2 -2
- package/packages/utility/src/image/settings.ts +178 -0
- package/packages/{image/src/image-source.ts → utility/src/image/source.ts} +1 -1
- package/packages/utility/src/image/tools.ts +168 -0
- package/packages/utility/src/image/transports.ts +186 -0
- package/packages/utility/src/index.ts +68 -389
- package/packages/utility/src/lifecycle/cleanup.ts +116 -311
- package/packages/utility/src/rename/gate.ts +102 -0
- package/packages/utility/src/rename/index.ts +191 -0
- package/packages/utility/src/rename/session.ts +170 -0
- package/packages/utility/src/render/delegated.ts +285 -0
- package/packages/utility/src/render/harness.ts +683 -0
- package/packages/utility/src/render/parse.ts +156 -0
- package/packages/utility/src/render/pi-settings.ts +38 -0
- package/packages/utility/src/render/reply-bg.ts +307 -0
- package/packages/utility/src/render/simple.ts +724 -0
- package/packages/utility/src/render/spacing.ts +235 -0
- package/packages/utility/src/render/styled.ts +251 -0
- package/packages/utility/src/render/tools.ts +353 -0
- package/packages/utility/src/settings.ts +166 -233
- package/packages/utility/src/types.ts +1 -222
- package/packages/watchdog/index.ts +17 -6
- package/packages/watchdog/package.json +3 -3
- package/packages/watchdog/src/config.ts +3 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/README.md +3 -0
- package/packages/workflow/index.ts +0 -2
- package/packages/workflow/package.json +2 -2
- package/packages/workflow/src/permission/decide.ts +6 -4
- package/packages/workflow/src/permission/index.ts +17 -7
- package/packages/workflow/src/permission/settings.ts +39 -4
- package/packages/workflow/src/plan/index.ts +19 -4
- package/packages/background-tasks/extensions/delegate-child.ts +0 -1
- package/packages/background-tasks/src/context-parent-snapshot.ts +0 -142
- package/packages/background-tasks/src/context-token-budget.ts +0 -903
- package/packages/background-tasks/src/context-visible-conversation-v2.ts +0 -551
- package/packages/background-tasks/src/delegate/artifacts.ts +0 -487
- package/packages/background-tasks/src/delegate/budget.ts +0 -415
- package/packages/background-tasks/src/delegate/hook-contract-evidence.json +0 -18
- package/packages/background-tasks/src/delegate/hook-contract.ts +0 -154
- package/packages/background-tasks/src/delegate/launch.ts +0 -484
- package/packages/background-tasks/src/delegate/result-package.ts +0 -459
- package/packages/background-tasks/src/delegate/runner.ts +0 -430
- package/packages/background-tasks/src/delegate/seed.ts +0 -423
- package/packages/background-tasks/src/delegate/types.ts +0 -323
- package/packages/background-tasks/src/delegate-child-extension.ts +0 -978
- package/packages/background-tasks/src/delegate-extension.ts +0 -654
- package/packages/background-tasks/src/fixtures/delegate-context-incident.json +0 -17
- package/packages/compactor/skills/compactor-detail/SKILL.md +0 -116
- package/packages/compactor/skills/compactor-doctor/SKILL.md +0 -71
- package/packages/compactor/skills/compactor-stats/SKILL.md +0 -51
- package/packages/compactor/src/compaction/build-sections.ts +0 -76
- package/packages/compactor/src/compaction/extract/goals.ts +0 -36
- package/packages/compactor/src/compaction/extract/preferences.ts +0 -40
- package/packages/compactor/src/compaction/format.ts +0 -53
- package/packages/compactor/src/compaction/merge.ts +0 -143
- package/packages/compactor/src/config/presets.ts +0 -160
- package/packages/compactor/src/executor/executor.ts +0 -233
- package/packages/compactor/src/executor/runtime.ts +0 -115
- package/packages/compactor/src/security/evaluator.ts +0 -89
- package/packages/compactor/src/security/policy.ts +0 -96
- package/packages/compactor/src/security/scanner.ts +0 -65
- package/packages/compactor/src/session/auto-inject.ts +0 -60
- package/packages/compactor/src/session/db.ts +0 -325
- package/packages/compactor/src/session/extract.ts +0 -144
- package/packages/compactor/src/session/resume-inject.ts +0 -64
- package/packages/compactor/src/session/snapshot.ts +0 -327
- package/packages/compactor/src/tools/ctx-batch-execute.ts +0 -41
- package/packages/compactor/src/tools/ctx-execute-file.ts +0 -23
- package/packages/compactor/src/tools/ctx-execute.ts +0 -23
- package/packages/compactor/src/tools/ctx-stats.ts +0 -96
- package/packages/image/README.md +0 -99
- package/packages/image/package.json +0 -61
- package/packages/image/skills/image/SKILL.md +0 -76
- package/packages/image/src/generate.ts +0 -256
- package/packages/image/src/index.ts +0 -135
- package/packages/image/src/models.ts +0 -519
- package/packages/image/src/openai-images-api.ts +0 -282
- package/packages/image/src/register-providers.ts +0 -209
- package/packages/image/src/settings.ts +0 -173
- package/packages/image/src/tools.ts +0 -326
- package/packages/input-shortcuts/src/clipboard.ts +0 -119
- package/packages/kanboard/src/runner.ts +0 -817
- package/packages/skill-registry/skills/auto/SKILL.md +0 -465
- package/packages/skill-registry/skills/brainstorm/SKILL.md +0 -219
- package/packages/skill-registry/skills/chore-create/SKILL.md +0 -315
- package/packages/skill-registry/skills/chore-execute/SKILL.md +0 -314
- package/packages/skill-registry/skills/consolidate/SKILL.md +0 -142
- package/packages/skill-registry/skills/consultant/SKILL.md +0 -97
- package/packages/skill-registry/skills/debug/SKILL.md +0 -228
- package/packages/skill-registry/skills/document/SKILL.md +0 -120
- package/packages/skill-registry/skills/fix/SKILL.md +0 -212
- package/packages/skill-registry/skills/gather-context/SKILL.md +0 -145
- package/packages/skill-registry/skills/plan/SKILL.md +0 -231
- package/packages/skill-registry/skills/quick-fix/SKILL.md +0 -135
- package/packages/skill-registry/skills/quick-work/SKILL.md +0 -110
- package/packages/skill-registry/skills/research/SKILL.md +0 -251
- package/packages/skill-registry/skills/review-work/SKILL.md +0 -187
- package/packages/skill-registry/skills/scan-issues/SKILL.md +0 -195
- package/packages/skill-registry/skills/work/SKILL.md +0 -218
- package/packages/skill-registry/skills/worktree-create/SKILL.md +0 -69
- package/packages/skill-registry/skills/worktree-list/SKILL.md +0 -78
- package/packages/skill-registry/skills/worktree-merge/SKILL.md +0 -89
- package/packages/subagents/agents/delegate.md +0 -14
- package/packages/subagents/agents/oracle.md +0 -78
- package/packages/subagents/agents/researcher.md +0 -52
- package/packages/subagents/agents/reviewer.md +0 -80
- package/packages/subagents/agents/scout.md +0 -50
- package/packages/subagents/agents/worker.md +0 -59
- package/packages/subagents/prompts/council.md +0 -48
- package/packages/subagents/prompts/gather-context-and-clarify.md +0 -13
- package/packages/subagents/prompts/parallel-cleanup.md +0 -59
- package/packages/subagents/prompts/parallel-research.md +0 -50
- package/packages/subagents/prompts/parallel-review.md +0 -54
- package/packages/subagents/prompts/review-loop.md +0 -43
- package/packages/subagents/skills/council-mode/SKILL.md +0 -230
- package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +0 -259
- package/packages/subagents/skills/subagents/references/execution-controls.md +0 -454
- package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +0 -161
- package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +0 -39
- package/packages/subagents/skills/subagents/references/prompting-and-roles.md +0 -267
- package/packages/subagents/src/acceptance.ts +0 -331
- package/packages/subagents/src/agent-manager.ts +0 -405
- package/packages/subagents/src/agent-memory.ts +0 -300
- package/packages/subagents/src/agent-overrides.ts +0 -183
- package/packages/subagents/src/agent-runner.ts +0 -271
- package/packages/subagents/src/async-runner.ts +0 -422
- package/packages/subagents/src/authority-policy.ts +0 -47
- package/packages/subagents/src/budgets.ts +0 -285
- package/packages/subagents/src/child-safety.ts +0 -140
- package/packages/subagents/src/config.ts +0 -249
- package/packages/subagents/src/conversation-viewer.ts +0 -281
- package/packages/subagents/src/core-compat.ts +0 -94
- package/packages/subagents/src/custom-agents.ts +0 -343
- package/packages/subagents/src/file-system-retry.ts +0 -50
- package/packages/subagents/src/fleet-data.ts +0 -105
- package/packages/subagents/src/fleet-view.ts +0 -322
- package/packages/subagents/src/foreground-detach.ts +0 -59
- package/packages/subagents/src/fork-context.ts +0 -285
- package/packages/subagents/src/global.d.ts +0 -7
- package/packages/subagents/src/guide.ts +0 -129
- package/packages/subagents/src/mission-state.ts +0 -133
- package/packages/subagents/src/mission-store.ts +0 -436
- package/packages/subagents/src/output-limits.ts +0 -142
- package/packages/subagents/src/parity-types.ts +0 -315
- package/packages/subagents/src/pi-args.ts +0 -167
- package/packages/subagents/src/result-files.ts +0 -232
- package/packages/subagents/src/result-watcher.ts +0 -221
- package/packages/subagents/src/retained-children.ts +0 -176
- package/packages/subagents/src/run-fanout-budget.ts +0 -356
- package/packages/subagents/src/scheduled-runs.ts +0 -349
- package/packages/subagents/src/schemas.ts +0 -263
- package/packages/subagents/src/slash-commands.ts +0 -80
- package/packages/subagents/src/supervisor-channel.ts +0 -249
- package/packages/subagents/src/tool-handler.ts +0 -1144
- package/packages/subagents/src/types.ts +0 -238
- package/packages/subagents/src/widget.ts +0 -457
- package/packages/subagents/src/workflow-script.ts +0 -737
- package/packages/subagents/src/workflow-worker.ts +0 -384
- package/packages/subagents/src/worktree.ts +0 -614
- package/packages/utility/src/analytics/collector.ts +0 -293
- package/packages/utility/src/info-screen.ts +0 -80
- package/packages/utility/src/lifecycle/process.ts +0 -162
- package/packages/utility/src/prefix-cache.ts +0 -263
- package/packages/utility/src/skill-discovery.ts +0 -542
- package/packages/utility/src/tools/env.ts +0 -113
- package/packages/utility/src/tui/name-badge-state.ts +0 -315
- package/packages/utility/src/tui/name-badge.ts +0 -109
- /package/packages/{subagents/src → core/src/child-agent}/pi-spawn.ts +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — /unipi:answer
|
|
3
|
+
*
|
|
4
|
+
* Answer the questions in the agent's last reply without scrolling:
|
|
5
|
+
* editor — pi's editor pre-filled with a Q/A template (Ctrl+G → $EDITOR)
|
|
6
|
+
* web — a local form with the full reply beside the answer boxes
|
|
7
|
+
* `/unipi:answer editor|web` overrides the configured default. Over SSH the
|
|
8
|
+
* web form prints a port-forward command instead of opening a browser.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { hostname } from "node:os";
|
|
13
|
+
import { ExtensionEditorComponent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
15
|
+
import { boxInnerWidth, frameOverlay, HUB_OVERLAY_OPTIONS, hubBoldText as bold, hubDimText as dim, hubExactRow, hubTheme, setHubTheme, UNIPI_PREFIX, UTILITY_COMMANDS } from "@pi-unipi/core";
|
|
16
|
+
import { readUtilSettings } from "../settings.js";
|
|
17
|
+
import { ReplyPanel, type ReplyPanelResult } from "./reply.js";
|
|
18
|
+
import { buildTemplate, composeAnswers, extractQuestions, messageText, parseTemplate } from "./extract.js";
|
|
19
|
+
import { startWebForm, type WebAnswer } from "./web.js";
|
|
20
|
+
|
|
21
|
+
/** Fixed port tried first over SSH, so one `ssh -L` keeps working. */
|
|
22
|
+
export const SSH_DEFAULT_PORT = 47321;
|
|
23
|
+
|
|
24
|
+
export function isSsh(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
25
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lastAssistantText(ctx: ExtensionContext): string {
|
|
29
|
+
const sm = ctx.sessionManager as unknown as { getBranch?: () => unknown[]; getEntries: () => unknown[] };
|
|
30
|
+
const entries = (sm.getBranch?.() ?? sm.getEntries()) as Array<{ type?: string; message?: { role?: string; content?: unknown } }>;
|
|
31
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
32
|
+
const m = entries[i]!.message;
|
|
33
|
+
if (entries[i]!.type === "message" && m?.role === "assistant") {
|
|
34
|
+
const text = messageText(m.content).trim();
|
|
35
|
+
if (text) return text;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function openBrowser(url: string): void {
|
|
42
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
43
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
44
|
+
try {
|
|
45
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
|
|
46
|
+
} catch {
|
|
47
|
+
// no browser — the URL is on screen
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* pi's own editor dialog (Enter submits, Ctrl+G opens $EDITOR), hosted here so
|
|
53
|
+
* the cursor can start on the first answer line instead of the end.
|
|
54
|
+
*/
|
|
55
|
+
function answerEditor(ctx: ExtensionContext, title: string, template: string): Promise<string | undefined> {
|
|
56
|
+
const target = template.split("\n").findIndex((l) => l.startsWith("A1:"));
|
|
57
|
+
return ctx.ui.custom<string | undefined>((tui, _theme, keybindings, done) => {
|
|
58
|
+
const component = new ExtensionEditorComponent(tui, keybindings, title, template, (text: string) => done(text), () => done(undefined));
|
|
59
|
+
const editor = (component as unknown as { editor: { getCursor(): { line: number }; getText(): string; handleInput(d: string): void } }).editor;
|
|
60
|
+
const goTo = (line: number) => {
|
|
61
|
+
for (let i = 0; i < 500 && editor.getCursor().line > line; i++) editor.handleInput("\x1b[A");
|
|
62
|
+
for (let i = 0; i < 500 && editor.getCursor().line < line; i++) editor.handleInput("\x1b[B");
|
|
63
|
+
editor.handleInput("\x1b[F");
|
|
64
|
+
};
|
|
65
|
+
if (target >= 0) goTo(target);
|
|
66
|
+
// Tab / Shift+Tab jump between answer lines (wrapping around).
|
|
67
|
+
const inner = component.handleInput.bind(component);
|
|
68
|
+
component.handleInput = (data: string) => {
|
|
69
|
+
if (data !== "\t" && data !== "\x1b[Z") return inner(data);
|
|
70
|
+
const answers = editor.getText().split("\n").map((l, i) => (/^A\d+:/.test(l) ? i : -1)).filter((i) => i >= 0);
|
|
71
|
+
if (answers.length === 0) return;
|
|
72
|
+
const at = editor.getCursor().line;
|
|
73
|
+
const next = data === "\t"
|
|
74
|
+
? answers.find((i) => i > at) ?? answers[0]!
|
|
75
|
+
: [...answers].reverse().find((i) => i < at) ?? answers.at(-1)!;
|
|
76
|
+
goTo(next);
|
|
77
|
+
tui.requestRender();
|
|
78
|
+
};
|
|
79
|
+
return component;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Small capturing overlay while the browser form is open; Esc cancels. */
|
|
84
|
+
function waitForWeb(ctx: ExtensionContext, lines: string[], result: Promise<WebAnswer | null>, cancel: () => void): Promise<WebAnswer | null> {
|
|
85
|
+
return ctx.ui.custom<WebAnswer | null>((tui, theme, _kb, done) => {
|
|
86
|
+
setHubTheme(theme);
|
|
87
|
+
void result.then((r) => done(r));
|
|
88
|
+
return {
|
|
89
|
+
focused: true,
|
|
90
|
+
render: (width: number) => {
|
|
91
|
+
const inner = boxInnerWidth(width);
|
|
92
|
+
return frameOverlay([...lines.map((l) => hubExactRow(` ${l}`, inner)), hubExactRow(dim(" Waiting for your answers… esc cancels"), inner)], width, {
|
|
93
|
+
title: bold(" answer — web form "),
|
|
94
|
+
borderFg: (t) => hubTheme.fg("borderMuted", t),
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
invalidate: () => tui.requestRender(),
|
|
98
|
+
handleInput: (data: string) => {
|
|
99
|
+
// matchesKey: kitty-protocol terminals send Esc as a CSI sequence, not a bare \x1b.
|
|
100
|
+
if (matchesKey(data, Key.escape) || data === "\x1b" || data === "\x03") {
|
|
101
|
+
cancel();
|
|
102
|
+
done(null);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}, HUB_OVERLAY_OPTIONS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const METHODS = [
|
|
110
|
+
{ value: "reply", label: "reply", description: "The reply above a fixed input box — scroll it while you type" },
|
|
111
|
+
{ value: "questions", label: "questions", description: "One answer per detected question (Q/A template)" },
|
|
112
|
+
{ value: "web", label: "web", description: "Answer the questions in a browser form (works over SSH)" },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** Reply panel in place of the input area (no overlay: pi restores the editor). */
|
|
116
|
+
function replyPanel(ctx: ExtensionContext, reply: string, questions: number): Promise<ReplyPanelResult> {
|
|
117
|
+
return ctx.ui.custom<ReplyPanelResult>((tui, theme, _kb, done) => new ReplyPanel(tui, theme, reply, questions, done));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const HINT_WIDGET = "unipi-answer-hint";
|
|
121
|
+
|
|
122
|
+
export function registerAnswerCommand(pi: ExtensionAPI): void {
|
|
123
|
+
// "N questions detected" hint above the editor after a reply that asks some;
|
|
124
|
+
// cleared as soon as the user sends anything.
|
|
125
|
+
let hinted = false;
|
|
126
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
127
|
+
try {
|
|
128
|
+
if (!ctx.hasUI || !readUtilSettings(ctx.cwd).answer.hint) return;
|
|
129
|
+
const n = extractQuestions(lastAssistantText(ctx)).length;
|
|
130
|
+
if (n === 0) return;
|
|
131
|
+
ctx.ui.setWidget(HINT_WIDGET, [dim(` ${n} question${n === 1 ? "" : "s"} in the reply — /unipi:answer questions to answer them one by one`)], { placement: "aboveEditor" });
|
|
132
|
+
hinted = true;
|
|
133
|
+
} catch {
|
|
134
|
+
// hint is cosmetic
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const clearHint = (ctx: ExtensionContext) => {
|
|
138
|
+
if (!hinted) return;
|
|
139
|
+
hinted = false;
|
|
140
|
+
try {
|
|
141
|
+
ctx.ui.setWidget(HINT_WIDGET, undefined);
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
// Any new turn (typed, /unipi:answer, or an extension message) clears it.
|
|
147
|
+
pi.on("input", (_event, ctx) => clearHint(ctx));
|
|
148
|
+
pi.on("before_agent_start", (_event, ctx) => clearHint(ctx));
|
|
149
|
+
|
|
150
|
+
pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.ANSWER}`, {
|
|
151
|
+
description: "Answer the last reply — a fixed input box under the scrollable reply (reply), per question (questions), or a browser form (web)",
|
|
152
|
+
getArgumentCompletions: (prefix: string) => {
|
|
153
|
+
const needle = (prefix ?? "").trim().toLowerCase();
|
|
154
|
+
const matches = METHODS.filter((m) => m.value.startsWith(needle));
|
|
155
|
+
return matches.length > 0 ? matches : null;
|
|
156
|
+
},
|
|
157
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
158
|
+
if (!ctx.hasUI) {
|
|
159
|
+
ctx.ui?.notify?.("/unipi:answer needs the interactive TUI", "warning");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const reply = lastAssistantText(ctx);
|
|
163
|
+
if (!reply) {
|
|
164
|
+
ctx.ui.notify("No agent reply to answer yet.", "info");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const questions = extractQuestions(reply);
|
|
168
|
+
const settings = readUtilSettings(ctx.cwd).answer;
|
|
169
|
+
const arg = args.trim().toLowerCase();
|
|
170
|
+
let method: "reply" | "questions" | "web" =
|
|
171
|
+
arg === "web" ? "web" : arg === "questions" || arg === "editor" ? "questions" : arg === "reply" ? "reply" : settings.method;
|
|
172
|
+
if (method !== "reply" && questions.length === 0 && method === "web") method = "reply";
|
|
173
|
+
|
|
174
|
+
let message: string | undefined;
|
|
175
|
+
if (method === "reply") {
|
|
176
|
+
const result = await replyPanel(ctx, reply, questions.length);
|
|
177
|
+
if (result.type === "cancel") return;
|
|
178
|
+
if (result.type === "send") message = result.text;
|
|
179
|
+
else method = "questions";
|
|
180
|
+
}
|
|
181
|
+
if (method === "questions") {
|
|
182
|
+
const title = questions.length
|
|
183
|
+
? `Answer ${questions.length} question${questions.length === 1 ? "" : "s"} — Tab next answer · Ctrl+G your editor`
|
|
184
|
+
: "Reply to the last message (no questions found) — Ctrl+G your editor";
|
|
185
|
+
const text = await answerEditor(ctx, title, buildTemplate(questions));
|
|
186
|
+
if (text === undefined) return;
|
|
187
|
+
message = composeAnswers(questions, parseTemplate(text, questions.length));
|
|
188
|
+
} else if (method === "web") {
|
|
189
|
+
const ssh = isSsh();
|
|
190
|
+
const port = settings.port > 0 ? settings.port : ssh ? SSH_DEFAULT_PORT : 0;
|
|
191
|
+
const form = await startWebForm(reply, questions, port).catch(() => startWebForm(reply, questions, 0));
|
|
192
|
+
const lines = ssh
|
|
193
|
+
? [
|
|
194
|
+
bold("Open on your own machine:"),
|
|
195
|
+
form.url,
|
|
196
|
+
dim("First forward the port from that machine:"),
|
|
197
|
+
`ssh -L ${form.port}:127.0.0.1:${form.port} ${hostname()}`,
|
|
198
|
+
]
|
|
199
|
+
: [bold("Opened in your browser:"), form.url];
|
|
200
|
+
if (!ssh) openBrowser(form.url);
|
|
201
|
+
const answer = await waitForWeb(ctx, lines, form.result, form.close);
|
|
202
|
+
form.close();
|
|
203
|
+
if (!answer) return;
|
|
204
|
+
message = composeAnswers(questions, answer.answers, answer.note);
|
|
205
|
+
}
|
|
206
|
+
if (!message) {
|
|
207
|
+
ctx.ui.notify("Nothing answered — nothing sent.", "info");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — /unipi:answer "reply" panel (the default method)
|
|
3
|
+
*
|
|
4
|
+
* Like /unipi:btw: the panel replaces the input area (ctx.ui.custom without
|
|
5
|
+
* `overlay`; pi restores the editor and your draft when it closes). The agent's
|
|
6
|
+
* last reply is shown in a scrollable viewport above a FIXED input box, so you
|
|
7
|
+
* can read any part of a long reply while typing — nothing scrolls away.
|
|
8
|
+
*
|
|
9
|
+
* ── /unipi:answer · reply ───────────── 3 questions · tab answers them one by one ──
|
|
10
|
+
* …the reply, markdown…
|
|
11
|
+
* ────────────────────────────────────────────── (the input box's own top border)
|
|
12
|
+
* your answer (multi-line: shift+enter)
|
|
13
|
+
* ──────────────────────────────────────────────
|
|
14
|
+
* ↵ send · ↑↓ scroll · alt+↑↓ / pgup pgdn scroll while typing · … · lines 12–40 of 88
|
|
15
|
+
*
|
|
16
|
+
* ↑/↓ scroll the reply while the input is EMPTY; once you type they belong to
|
|
17
|
+
* the input (cursor movement in a multi-line answer). Alt+↑/↓ and PgUp/PgDn
|
|
18
|
+
* always scroll.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Editor, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
|
|
22
|
+
import { getMarkdownTheme, getSelectListTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
export type ReplyPanelResult = { type: "send"; text: string } | { type: "questions"; draft: string } | { type: "cancel" };
|
|
25
|
+
|
|
26
|
+
export class ReplyPanel implements Component, Focusable {
|
|
27
|
+
private readonly editor: Editor;
|
|
28
|
+
private _focused = false;
|
|
29
|
+
get focused(): boolean {
|
|
30
|
+
return this._focused;
|
|
31
|
+
}
|
|
32
|
+
set focused(value: boolean) {
|
|
33
|
+
this._focused = value;
|
|
34
|
+
this.editor.focused = value;
|
|
35
|
+
}
|
|
36
|
+
private scroll = 0;
|
|
37
|
+
/** Start at the end — questions usually close a reply. */
|
|
38
|
+
private atEnd = true;
|
|
39
|
+
private body: string[] = [];
|
|
40
|
+
private bodyWidth = 0;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly tui: TUI,
|
|
44
|
+
private readonly theme: Theme,
|
|
45
|
+
private readonly reply: string,
|
|
46
|
+
private readonly questionCount: number,
|
|
47
|
+
private readonly done: (result: ReplyPanelResult) => void,
|
|
48
|
+
draft = "",
|
|
49
|
+
) {
|
|
50
|
+
this.editor = new Editor(tui, { borderColor: (text: string) => theme.fg("borderMuted", text), selectList: getSelectListTheme() });
|
|
51
|
+
if (draft) this.editor.setText(draft);
|
|
52
|
+
this.editor.onSubmit = (text: string) => {
|
|
53
|
+
const value = text.trim();
|
|
54
|
+
if (value) this.done({ type: "send", text: value });
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
invalidate(): void {
|
|
59
|
+
this.bodyWidth = 0;
|
|
60
|
+
this.editor.invalidate?.();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private viewport(): number {
|
|
64
|
+
return Math.max(4, (process.stdout.rows ?? 30) - 12);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private lines(width: number): string[] {
|
|
68
|
+
if (this.bodyWidth !== width) {
|
|
69
|
+
const md = new Markdown(this.reply, 0, 0, getMarkdownTheme());
|
|
70
|
+
this.body = md.render(width).flatMap((l) => wrapTextWithAnsi(l, Math.max(1, width)));
|
|
71
|
+
this.bodyWidth = width;
|
|
72
|
+
}
|
|
73
|
+
return this.body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(width: number): string[] {
|
|
77
|
+
const w = Math.max(20, width);
|
|
78
|
+
const t = this.theme;
|
|
79
|
+
const body = this.lines(w - 1);
|
|
80
|
+
const view = this.viewport();
|
|
81
|
+
const max = Math.max(0, body.length - view);
|
|
82
|
+
if (this.atEnd) this.scroll = max;
|
|
83
|
+
this.scroll = Math.max(0, Math.min(this.scroll, max));
|
|
84
|
+
const shown = body.slice(this.scroll, this.scroll + view);
|
|
85
|
+
|
|
86
|
+
const title = " /unipi:answer · reply ";
|
|
87
|
+
const qs = this.questionCount > 0 ? ` ${this.questionCount} question${this.questionCount === 1 ? "" : "s"} · tab answers them one by one ` : "";
|
|
88
|
+
const top = `${t.fg("borderMuted", "──")}${t.fg("accent", title)}${t.fg("borderMuted", "─".repeat(Math.max(0, w - 2 - visibleWidth(title) - visibleWidth(qs))))}${t.fg("dim", qs)}`;
|
|
89
|
+
const pos = body.length > view ? ` · lines ${this.scroll + 1}–${this.scroll + shown.length} of ${body.length}` : "";
|
|
90
|
+
const scrollKeys = this.editor.getText() === "" ? "↑↓ scroll" : "alt+↑↓ scroll";
|
|
91
|
+
const hint = t.fg("dim", `↵ send · shift+↵ newline · ${scrollKeys} · pgup/pgdn page${this.questionCount > 0 ? " · tab questions" : ""} · esc back${pos}`);
|
|
92
|
+
|
|
93
|
+
// The input box draws its own top/bottom border — no extra rule above it.
|
|
94
|
+
return [
|
|
95
|
+
truncateToWidth(top, w, ""),
|
|
96
|
+
...shown.map((l) => truncateToWidth(l, w, "")),
|
|
97
|
+
...Array.from({ length: Math.max(0, view - shown.length) }, () => ""),
|
|
98
|
+
...this.editor.render(w),
|
|
99
|
+
truncateToWidth(hint, w, ""),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private scrollBy(delta: number): void {
|
|
104
|
+
const max = Math.max(0, this.body.length - this.viewport());
|
|
105
|
+
this.scroll = Math.max(0, Math.min(max, this.scroll + delta));
|
|
106
|
+
this.atEnd = this.scroll >= max;
|
|
107
|
+
this.tui.requestRender();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
handleInput(data: string): void {
|
|
111
|
+
if (matchesKey(data, Key.escape) || data === "\x1b") {
|
|
112
|
+
this.done({ type: "cancel" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (matchesKey(data, Key.tab) && this.questionCount > 0) {
|
|
116
|
+
this.done({ type: "questions", draft: this.editor.getText() });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const page = Math.max(1, this.viewport() - 2);
|
|
120
|
+
if (matchesKey(data, Key.pageUp)) return this.scrollBy(-page);
|
|
121
|
+
if (matchesKey(data, Key.pageDown)) return this.scrollBy(page);
|
|
122
|
+
if (matchesKey(data, "alt+up")) return this.scrollBy(-1);
|
|
123
|
+
if (matchesKey(data, "alt+down")) return this.scrollBy(1);
|
|
124
|
+
// Plain ↑/↓ scroll only while nothing is typed; after that they are the input's.
|
|
125
|
+
const empty = this.editor.getText() === "";
|
|
126
|
+
if (empty && matchesKey(data, Key.up)) return this.scrollBy(-1);
|
|
127
|
+
if (empty && matchesKey(data, Key.down)) return this.scrollBy(1);
|
|
128
|
+
this.editor.handleInput(data);
|
|
129
|
+
this.tui.requestRender();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
dispose(): void {}
|
|
133
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — /unipi:answer web form
|
|
3
|
+
*
|
|
4
|
+
* A one-shot local page: the agent's full reply on the left, one answer box
|
|
5
|
+
* per question on the right (plus a free-text note), submit → resolves. Bound
|
|
6
|
+
* to 127.0.0.1 with a random token in the URL; over SSH the caller shows a
|
|
7
|
+
* port-forward command instead of opening a browser.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { createServer, type Server } from "node:http";
|
|
12
|
+
|
|
13
|
+
export interface WebAnswer {
|
|
14
|
+
answers: string[];
|
|
15
|
+
note: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WebForm {
|
|
19
|
+
url: string;
|
|
20
|
+
port: number;
|
|
21
|
+
result: Promise<WebAnswer | null>;
|
|
22
|
+
close: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
26
|
+
|
|
27
|
+
/** Tiny markdown → HTML for the reply pane: fences, headings, lists, bold, code. */
|
|
28
|
+
export function renderReply(md: string): string {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
let code: string[] | null = null;
|
|
31
|
+
let list = false;
|
|
32
|
+
let table: string[][] | null = null;
|
|
33
|
+
const inline = (s: string) => esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
|
|
34
|
+
const flushTable = () => {
|
|
35
|
+
if (!table) return;
|
|
36
|
+
const [head, ...body] = table;
|
|
37
|
+
out.push(`<table><thead><tr>${head!.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>${body.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`);
|
|
38
|
+
table = null;
|
|
39
|
+
};
|
|
40
|
+
for (const line of md.split("\n")) {
|
|
41
|
+
if (!code && /^\s*\|.*\|\s*$/.test(line)) {
|
|
42
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue; // separator row
|
|
43
|
+
(table ??= []).push(line.trim().slice(1, -1).split("|").map((c) => c.trim()));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
flushTable();
|
|
47
|
+
if (/^\s*```/.test(line)) {
|
|
48
|
+
if (code) {
|
|
49
|
+
out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
|
|
50
|
+
code = null;
|
|
51
|
+
} else code = [];
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (code) {
|
|
55
|
+
code.push(line);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const li = line.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
|
|
59
|
+
if (li) {
|
|
60
|
+
if (!list) out.push("<ul>");
|
|
61
|
+
list = true;
|
|
62
|
+
out.push(`<li>${inline(li[1]!)}</li>`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (list) {
|
|
66
|
+
out.push("</ul>");
|
|
67
|
+
list = false;
|
|
68
|
+
}
|
|
69
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
70
|
+
if (h) out.push(`<h${Math.min(4, h[1]!.length + 1)}>${inline(h[2]!)}</h${Math.min(4, h[1]!.length + 1)}>`);
|
|
71
|
+
else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
|
|
72
|
+
}
|
|
73
|
+
flushTable();
|
|
74
|
+
if (code) out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
|
|
75
|
+
if (list) out.push("</ul>");
|
|
76
|
+
return out.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formPage(reply: string, questions: readonly string[], token: string): string {
|
|
80
|
+
const qs = questions.length ? questions : ["Your reply"];
|
|
81
|
+
const fields = qs.map((q, i) => `
|
|
82
|
+
<label><span class="n">${i + 1}</span><span class="q">${esc(q)}</span></label>
|
|
83
|
+
<textarea name="a${i}" rows="3" placeholder="Answer (empty = skip)"></textarea>`).join("");
|
|
84
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
85
|
+
<title>Answer — pi</title>
|
|
86
|
+
<style>
|
|
87
|
+
:root{color-scheme:dark;--bg:#16181d;--panel:#1e2128;--line:#2c313a;--fg:#d7dae0;--dim:#8a919e;--accent:#53a0d7}
|
|
88
|
+
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,sans-serif}
|
|
89
|
+
main{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);height:100vh}
|
|
90
|
+
section{overflow:auto;padding:24px 28px} .reply{border-right:1px solid var(--line)}
|
|
91
|
+
h1{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin:0 0 16px}
|
|
92
|
+
table{border-collapse:collapse;margin:10px 0} th,td{border:1px solid var(--line);padding:5px 10px;text-align:left} th{color:var(--dim);font-weight:600}
|
|
93
|
+
pre{background:var(--panel);padding:10px 12px;border-radius:6px;overflow:auto} code{font:12.5px ui-monospace,monospace}
|
|
94
|
+
label{display:flex;gap:10px;margin:18px 0 6px} .n{color:var(--accent);font-weight:600} .q{flex:1}
|
|
95
|
+
textarea{width:100%;background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font:inherit;resize:vertical}
|
|
96
|
+
textarea:focus{outline:none;border-color:var(--accent)}
|
|
97
|
+
.bar{position:sticky;bottom:0;background:var(--bg);padding:14px 0;display:flex;gap:12px;align-items:center}
|
|
98
|
+
button{background:var(--accent);color:#0b0d10;border:0;border-radius:6px;padding:9px 18px;font-weight:600;cursor:pointer}
|
|
99
|
+
.hint{color:var(--dim);font-size:12px} .done{padding:40px;font-size:16px}
|
|
100
|
+
@media (max-width:900px){main{grid-template-columns:1fr;height:auto}.reply{border-right:0;border-bottom:1px solid var(--line)}}
|
|
101
|
+
</style></head><body><main>
|
|
102
|
+
<section class="reply"><h1>Agent's reply</h1>${renderReply(reply)}</section>
|
|
103
|
+
<section><h1>Your answers</h1>
|
|
104
|
+
<form id="f">${fields}
|
|
105
|
+
<label><span class="q">Anything else</span></label>
|
|
106
|
+
<textarea name="note" rows="3" placeholder="Optional note"></textarea>
|
|
107
|
+
<div class="bar"><button type="submit">Send to pi</button><span class="hint">Ctrl+Enter sends</span></div>
|
|
108
|
+
</form>
|
|
109
|
+
</section>
|
|
110
|
+
</main>
|
|
111
|
+
<script>
|
|
112
|
+
const f=document.getElementById("f");
|
|
113
|
+
const send=async()=>{const d=new FormData(f);const answers=${JSON.stringify(qs.map((_, i) => `a${i}`))}.map(k=>d.get(k)||"");
|
|
114
|
+
const r=await fetch("/a/${token}",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({answers,note:d.get("note")||""})});
|
|
115
|
+
document.body.innerHTML='<div class="done">'+(r.ok?"Sent to pi. You can close this tab.":"Could not send — is pi still waiting?")+'</div>';};
|
|
116
|
+
f.addEventListener("submit",e=>{e.preventDefault();send();});
|
|
117
|
+
f.addEventListener("keydown",e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey)){e.preventDefault();send();}});
|
|
118
|
+
document.querySelector("textarea")?.focus();
|
|
119
|
+
</script></body></html>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Start the form server. `port` 0 = any free port. */
|
|
123
|
+
export function startWebForm(reply: string, questions: readonly string[], port = 0): Promise<WebForm> {
|
|
124
|
+
const token = randomBytes(12).toString("hex");
|
|
125
|
+
let settle: (v: WebAnswer | null) => void = () => {};
|
|
126
|
+
const result = new Promise<WebAnswer | null>((resolve) => { settle = resolve; });
|
|
127
|
+
const page = formPage(reply, questions, token);
|
|
128
|
+
const server: Server = createServer((req, res) => {
|
|
129
|
+
if (req.url !== `/a/${token}`) {
|
|
130
|
+
res.writeHead(404).end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.method === "GET") {
|
|
134
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(page);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (req.method === "POST") {
|
|
138
|
+
let body = "";
|
|
139
|
+
req.on("data", (c) => { body += c; if (body.length > 1_000_000) req.destroy(); });
|
|
140
|
+
req.on("end", () => {
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(body) as { answers?: unknown; note?: unknown };
|
|
143
|
+
const answers = Array.isArray(data.answers) ? data.answers.map((a) => String(a ?? "")) : [];
|
|
144
|
+
res.writeHead(200).end("ok");
|
|
145
|
+
settle({ answers, note: typeof data.note === "string" ? data.note : "" });
|
|
146
|
+
} catch {
|
|
147
|
+
res.writeHead(400).end();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
res.writeHead(405).end();
|
|
153
|
+
});
|
|
154
|
+
const close = () => {
|
|
155
|
+
settle(null);
|
|
156
|
+
server.close();
|
|
157
|
+
server.closeAllConnections?.();
|
|
158
|
+
};
|
|
159
|
+
void result.then(() => setTimeout(() => { server.close(); server.closeAllConnections?.(); }, 500).unref());
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
server.once("error", reject);
|
|
162
|
+
server.listen(port, "127.0.0.1", () => {
|
|
163
|
+
const actual = (server.address() as { port: number }).port;
|
|
164
|
+
resolve({ url: `http://127.0.0.1:${actual}/a/${token}`, port: actual, result, close });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|