killeros 3.0.3 → 3.0.5
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 +28 -0
- package/README.md +34 -6
- package/killeros/auto-compaction.ts +23 -2
- package/killeros/goal-interface.ts +26 -15
- package/killeros/goal-runtime.ts +30 -2
- package/killeros/goal-settlement.ts +17 -1
- package/killeros/notifications.ts +2 -2
- package/killeros/passive-git-status.ts +2 -1
- package/killeros/question-ui.ts +173 -494
- package/killeros/question.ts +27 -20
- package/killeros/runtime.ts +5 -0
- package/killeros/worked-for.ts +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to KillerOS are documented here. Releases use New Features f
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.0.5] - 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Used Pi 1.1.0's final cancellation signal for `Stopped` task receipts and silent completion notifications, including cancellation after a normal assistant response or without an assistant result. Kept observed changes and checks and the Pi 1.0.4 fallback.
|
|
12
|
+
- Stopped accepted goal continuation after final request cancellation while preserving compaction recovery through its completion and cancellation callbacks.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added real Pi 1.1.0 cancellation, busy-error retry, native Mistral transport retry, and additive tool-selection checks. Documented native tool modifiers and reliable extension-tool exclusion through `--exclude-tools`, including Pi 1.1.0's modifier limitation. Kept the Pi and Node support floors and locked development packages unchanged.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Moved question input to Pi's native dialogs, with KillerOS coordinating bounded multi-select, filtering, custom answers, recent-answer history, and paged text previews. Each step uses native dialog controls and Pi 1.1.0's blocked-status reporting. Preserved the question tool's inputs and results, TUI restriction, and sequential model-only exposure; cancellation and session changes prevent stale steps or partial success.
|
|
21
|
+
|
|
22
|
+
## [3.0.4] - 2026-10-07
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Made the explicitly enabled `prepublishOnly` lifecycle the single release-validator invocation at npm publication. The release workflow now publishes the source directory with `npm publish . --ignore-scripts=false`, preserving the existing release checks and trusted publishing.
|
|
27
|
+
- Added current release-metadata validation and isolated publication-lifecycle tests with release-pinned npm to the Node-floor quality check. Extended the existing packed-package Pi test to check all required files and package identity without another pack operation. The package smoke job remains until replacement coverage lands and the required-check migration is approved and verified.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Allowed later verified ordinary work to complete the same saved blocked goal without resume, clear, or extra automatic turns. Completion retains the original objective, file baseline, and accounting, respects native tool selection, and prevents compaction from restarting completed work.
|
|
32
|
+
- Stopped false CRLF change counts in the footer and task receipts for text files with `eol=lf`. Added a differential Git-oracle matrix covering attributes, `core.autocrlf`, text and binary contents, plus mode and symlink comparisons. Fixes #47.
|
|
33
|
+
- Gave each real Pi decision-tool exposure scenario its own bounded timeout so slower Windows TUI scenarios do not cancel the remaining codemode and RPC coverage. All behavioral assertions remain unchanged.
|
|
34
|
+
|
|
7
35
|
## [3.0.3] - 2026-10-07
|
|
8
36
|
|
|
9
37
|
### Changed
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Or from GitHub:
|
|
|
36
36
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Pin a release by appending its tag, for example `@v3.0.
|
|
39
|
+
Pin a release by appending its tag, for example `@v3.0.5`. Add `-l` to install only for the current project. Restart Pi after installing.
|
|
40
40
|
|
|
41
41
|
## Commands
|
|
42
42
|
|
|
@@ -73,9 +73,25 @@ Pi 1.0 defaults to fullscreen. Use `--tui-mode regular` for one invocation or se
|
|
|
73
73
|
|
|
74
74
|
With Pi 1.0.3's default [keybindings](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/keybindings.md#fullscreen), `Home` and `End` move within the current editor line. In fullscreen mode, `Ctrl+Home` and `Ctrl+End` scroll to the transcript's beginning or end without moving the editor cursor. KillerOS inherits these bindings; user overrides still apply.
|
|
75
75
|
|
|
76
|
-
`question` and the active `killeros_goal_update` tool use Pi's `model-only` exposure. They stay directly declared to the model when codemode is disabled or enabled in `on` or `only` mode, but scripts and other tools cannot call them through `ctx.executeTool()`. The goal tool
|
|
76
|
+
`question` and the active `killeros_goal_update` tool use Pi's `model-only` exposure. They stay directly declared to the model when codemode is disabled or enabled in `on` or `only` mode, but scripts and other tools cannot call them through `ctx.executeTool()`. The goal tool is active for active goals and, during a later ordinary request, for completion only of a saved blocked goal. It is inactive outside those cases. Questions still require TUI mode; a direct RPC call fails with `The question tool requires interactive TUI mode`. KillerOS does not enable or configure codemode.
|
|
77
77
|
|
|
78
|
-
Pi 1.0.4's native `--tools` and `--exclude-tools` support `*` patterns. Quote patterns such as `"killeros_*"` so the shell does not expand them. An explicit allowlist must include `question` for interactive questions and permit `killeros_goal_update` for goals. Selection does not make questions usable outside TUI mode. Excluding the goal tool pauses goals before any model request. See Pi's [tool selection](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/cli.md#tools).
|
|
78
|
+
Pi 1.0.4's native `--tools` and `--exclude-tools` support `*` patterns. Quote patterns such as `"killeros_*"` so the shell does not expand them. An explicit allowlist must include `question` for interactive questions and permit `killeros_goal_update` for goals. Selection does not make questions usable outside TUI mode. Excluding the goal tool pauses goals that are active before any model request. A later ordinary request with a blocked goal still runs, but cannot record its completion when the tool is excluded. See Pi's [tool selection](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/cli.md#tools).
|
|
79
|
+
|
|
80
|
+
Pi 1.1.0 also accepts a list containing only exact `+name` and `-name` entries to change its default selection. For example, `pi --tools +codemode` adds codemode while retaining the default tools, and `pi --tools +codemode,-write` also removes `write` from that selection. Plain allowlists still replace the selection. Pi rejects mixed lists such as `read,+codemode` and modifier patterns such as `-killeros_*`. Use `--exclude-tools "killeros_*"` for wildcard exclusions.
|
|
81
|
+
|
|
82
|
+
In Pi 1.1.0, removing an extension tool with a `-name` modifier does not prevent Pi from activating it on registration or reload. To exclude the goal tool reliably, use `pi --exclude-tools killeros_goal_update`. KillerOS delegates selection to Pi; it does not parse or repair native CLI filters.
|
|
83
|
+
|
|
84
|
+
Pi 1.1.0 reports program status through OSC 7501 when the terminal supports it. KillerOS questions use Pi's native selectors and text inputs. Each open question step reports `blocked` and restores the previous status after an answer, cancellation, or abort. Existing native confirmation dialogs do the same. `PI_PROGRAM_STATUS=1` forces native reporting and `PI_PROGRAM_STATUS=0` disables it. KillerOS preserves this preference and lets Pi handle all status reporting. See Pi's [program status](https://github.com/earendil-works/pi/blob/v1.1.0/packages/coding-agent/docs/terminal-setup.md#program-status).
|
|
85
|
+
|
|
86
|
+
### Questions
|
|
87
|
+
|
|
88
|
+
The `question` tool asks one question per invocation. Single-select is the default; choosing a numbered option submits that answer. In multi-select mode, choosing an option toggles its selection and opens the selector again. Choose **Submit answers** when finished. KillerOS checks the requested minimum and maximum and returns the selected predefined answers in their original order, followed by an optional custom answer. Dialog steps stay within the same tool execution and do not start model requests or grant goal turns.
|
|
89
|
+
|
|
90
|
+
Choose **Custom answer** to open Pi's text input. Multi-select allows one custom answer, which you can edit or remove. Blank answers do not submit; custom answers accept up to 4,000 Unicode characters. Cancelling custom input returns to the selector with existing selections intact. **More actions** includes filtering, clearing a filter, reviewing the full question and option details, and selecting a recent custom answer. Filtering matches labels and descriptions without losing hidden selections. Filter queries accept up to 4,000 Unicode characters and 16,000 UTF-8 bytes.
|
|
91
|
+
|
|
92
|
+
Descriptions appear in bounded option summaries. Review shows full descriptions and proposal previews as paged plain text, including any markdown source. **Next page**, **Previous page**, and **Back** navigate the review. Long option lists and recent-answer history also use pages. Recent answers stay within the current session, with at most 100 distinct entries and 64 KiB of text; session changes clear them.
|
|
93
|
+
|
|
94
|
+
The flow uses Pi's native dialog keybindings and editing behavior. Multi-select takes successive selection steps; the former space-toggle and slash-filter shortcuts are replaced by explicit actions. Resizing selectors rebuilds their pages while preserving selections, and Pi resizes text input without discarding its draft. Cancelling the main selector discards unsubmitted answers. Cancelling a filter, history, or review step returns to the selector. Agent cancellation or session invalidation closes the question and prevents another step from opening.
|
|
79
95
|
|
|
80
96
|
## Configuration
|
|
81
97
|
|
|
@@ -109,7 +125,9 @@ A direct quoted path-shaped file target binds silent file proof. For an extensio
|
|
|
109
125
|
/goal Fix `killeros/footer.ts`, verified by npm test
|
|
110
126
|
```
|
|
111
127
|
|
|
112
|
-
Bare quoted prose remains model-reported. KillerOS captures the file baseline at goal start and only completes when the file is created or changed. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Same-turn recovery requires an actual interruption; successful or skipped compaction cannot restart a normally stopped goal without a decision. A request that completes or blocks a goal remains goal-owned through settlement, so automatic compaction cannot restart it as ordinary work. Later ordinary requests still support compaction continuation. Restored goals keep their persisted limit.
|
|
128
|
+
Bare quoted prose remains model-reported. KillerOS captures the file baseline at goal start and only completes when the file is created or changed. File proof verifies that deliverable, not every natural-language acceptance criterion. The assistant must audit the whole objective before reporting completion; KillerOS cannot independently prove arbitrary objectives from prose. A normal response never continues a goal by itself: the agent must record `continue`, `complete`, or a blocker decision through `killeros_goal_update`. Repeated continuation reports and unavailable goal tools pause the goal. New goals pause after 20 turns without warning. An explicit `/goal resume` on an exhausted goal grants another 20 turns; compaction recovery never grants turns. Same-turn recovery requires an actual interruption; successful or skipped compaction cannot restart a normally stopped goal without a decision. A request that completes or blocks a goal remains goal-owned through settlement, so automatic compaction cannot restart it as ordinary work. Later ordinary requests still support compaction continuation. Restored goals keep their persisted limit.
|
|
129
|
+
|
|
130
|
+
A blocked goal stays stopped. In a later ordinary TUI or RPC request in a saved session, the assistant can complete the same unchanged objective through `killeros_goal_update` after verification and current evidence. Only `complete` is accepted on this path. Completion does not resume automatic work, grant turns, restart the active clock, or require `/goal clear`. It retains the original file baseline and records either `file` proof or `model-reported` completion. Failed proof and ordinary responses without a completion decision leave the goal blocked. Paused goals still require explicit user controls. A completed goal leaves the footer but remains inspectable through `/goal` and session history.
|
|
113
131
|
|
|
114
132
|
Session replacement, reload, and committed tree navigation discard unfinished task receipts. Late receipt results do not write or notify through the old session context or attach to a different branch. Cancelled navigation preserves the pending receipt.
|
|
115
133
|
|
|
@@ -117,8 +135,12 @@ Pending `/goal` commands discard their mutation if committed navigation changes
|
|
|
117
135
|
|
|
118
136
|
Failed Git metadata reads keep the footer's last successful file counts and mark task changes unavailable. They are not treated as an empty repository.
|
|
119
137
|
|
|
138
|
+
Passive Git scans compare content without running repository filters. CRLF text that normalizes to the indexed LF content stays unchanged, including with `text=auto eol=lf` or bare `eol=lf`. Git's `status` can still report a modification after a size-changing rewrite because of its index stat cache, even when `git diff` is empty.
|
|
139
|
+
|
|
120
140
|
Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
|
|
121
141
|
|
|
142
|
+
On Pi 1.1.0, final request cancellation records `Stopped` and suppresses the completion sound even when the last assistant response ended normally. Recorded changes and checks remain in the receipt. Cancellation stops goal continuation and provider retry work; cancelling a pending compaction prevents its recovery. Pi 1.0.4 retains the assistant stop-reason fallback. Receipts and sounds still wait for final `agent_settled`, including across provider retries.
|
|
143
|
+
|
|
122
144
|
### Pi model and MCP settings
|
|
123
145
|
|
|
124
146
|
Use Pi's native `/thinking` selector for reasoning levels. Pi 1.0.2 can also apply different sampling parameters for each level through `samplingParamsByThinkingLevel` in `models.json`. For example, a model entry can contain:
|
|
@@ -163,9 +185,15 @@ npm ci && npm run check && npm test
|
|
|
163
185
|
|
|
164
186
|
`main` requires pull requests and the CI workflow's required checks, including for administrators. Force pushes and branch deletion are blocked. Pi compatibility checks use the stable names `Pi compatibility (minimum)` and `Pi compatibility (latest)`, independent of the tested versions.
|
|
165
187
|
|
|
166
|
-
When adopting these names, first confirm both checks passed on the release PR's exact head commit. Replace only the two old versioned Pi required checks with their corresponding stable names, preserving their GitHub Actions app bindings and every other protection.
|
|
188
|
+
When adopting these names, first confirm both checks passed on the release PR's exact head commit. Replace only the two old versioned Pi required checks with their corresponding stable names, preserving their GitHub Actions app bindings and every other protection.
|
|
189
|
+
|
|
190
|
+
`Quality (Node 22.19.0)` validates current release metadata before the full test suite. It also tests the publication lifecycle with the release workflow's pinned npm CLI in an isolated installation. The packed-package Pi test checks the installed archive's entry point, README, changelog, theme, package name, and current version before activation and reload. These archive assertions run throughout the Node, Windows, and Pi compatibility matrix.
|
|
191
|
+
|
|
192
|
+
`Package smoke test` remains during the required-check migration. Before removing it, land the replacement coverage and confirm the Node-floor quality check passed on the replacement PR's exact head commit and that the coverage is present on `main`. Inspect live branch protection and rulesets, obtain approval, and remove only the smoke requirement while preserving the required quality check's GitHub Actions binding and every other protection. Remove the job in a follow-up PR and recheck that PR's final head. Do not remove the job if these prerequisites are unmet.
|
|
193
|
+
|
|
194
|
+
Releases go through CI on `main`; do not push version tags manually. The release workflow publishes the source directory with `npm publish . --ignore-scripts=false`. The package's `prepublishOnly` lifecycle invokes the existing release validator once at that boundary, even if an inherited npm setting disables scripts. A validator failure stops publication and release creation. The earlier verified-commit, provenance, version, tag, and release-note checks remain.
|
|
167
195
|
|
|
168
|
-
|
|
196
|
+
The prepublish guard rejects ordinary direct publication, but it does not prove GitHub authorization. Disabling scripts or publishing a tarball bypasses `prepublishOnly`. Configure npm's trusted publisher for `release.yml`, set package publishing access to "Require two-factor authentication and disallow tokens", and revoke unused publish tokens. npm maintainers can still publish interactively with 2FA, so workflow-only publishing also depends on maintainer policy.
|
|
169
197
|
|
|
170
198
|
## Security
|
|
171
199
|
|
|
@@ -23,6 +23,7 @@ export interface AutoCompactionPreference {
|
|
|
23
23
|
|
|
24
24
|
export interface AutoCompactionGoalHandlers {
|
|
25
25
|
isActive(ctx: ExtensionContext): boolean;
|
|
26
|
+
hasAcceptedTerminalDecision(): boolean;
|
|
26
27
|
onRequested(): void;
|
|
27
28
|
onCompleted(ctx: ExtensionContext): void;
|
|
28
29
|
onFailed(ctx: ExtensionContext, error: unknown): void;
|
|
@@ -246,7 +247,20 @@ export function registerAutoCompaction(
|
|
|
246
247
|
goalRequestOwned = dependencies.goal?.isActive(ctx) === true;
|
|
247
248
|
});
|
|
248
249
|
|
|
250
|
+
// Capture later blocked completion before goal settlement discards its permission.
|
|
251
|
+
// A compaction already pending as ordinary work must also stop after that decision.
|
|
252
|
+
const observeTerminalDecision = (): void => {
|
|
253
|
+
if (!dependencies.goal?.hasAcceptedTerminalDecision()) return;
|
|
254
|
+
goalRequestOwned = true;
|
|
255
|
+
if (request?.phase === "compacting") {
|
|
256
|
+
request.goal = true;
|
|
257
|
+
if (request.compactionCompleted) request = undefined;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
pi.on("agent_end", observeTerminalDecision);
|
|
261
|
+
|
|
249
262
|
pi.on("turn_end", (_event, ctx) => {
|
|
263
|
+
observeTerminalDecision();
|
|
250
264
|
if (!supportedMode(ctx) || request) return;
|
|
251
265
|
|
|
252
266
|
let preference: AutoCompactionPreference;
|
|
@@ -305,9 +319,10 @@ export function registerAutoCompaction(
|
|
|
305
319
|
try {
|
|
306
320
|
ctx.compact({
|
|
307
321
|
onComplete: () => {
|
|
322
|
+
observeTerminalDecision();
|
|
308
323
|
if (request?.phase !== "compacting" || request.token !== token) return;
|
|
309
324
|
rearmOnNextLogicalRequest = preference.percentRemaining === 100;
|
|
310
|
-
if (goal) {
|
|
325
|
+
if (goal || request.goal) {
|
|
311
326
|
request = undefined;
|
|
312
327
|
if (activeGoal && dependencies.goal) {
|
|
313
328
|
try {
|
|
@@ -328,8 +343,14 @@ export function registerAutoCompaction(
|
|
|
328
343
|
}
|
|
329
344
|
});
|
|
330
345
|
|
|
331
|
-
pi.on("agent_settled", (
|
|
346
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
332
347
|
goalRequestOwned = false;
|
|
348
|
+
// Pi aborts the agent when our pending compaction starts. Its callback owns
|
|
349
|
+
// recovery, including cancellation; the settlement flag alone is ambiguous.
|
|
350
|
+
if ("aborted" in event && event.aborted === true && request?.phase !== "compacting") {
|
|
351
|
+
request = undefined;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
333
354
|
if (request?.phase === "continuation-dispatched") {
|
|
334
355
|
request = undefined;
|
|
335
356
|
notifyFailure(ctx, new Error("continuation was not accepted"));
|
|
@@ -13,7 +13,7 @@ import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
|
13
13
|
|
|
14
14
|
const GoalUpdateParams = Type.Object({
|
|
15
15
|
status: StringEnum(["complete", "continue", "blocked"] as const, {
|
|
16
|
-
description: "Record
|
|
16
|
+
description: "Record an active-goal decision, or complete a blocked goal during an eligible later ordinary request",
|
|
17
17
|
}),
|
|
18
18
|
evidence: Type.String({
|
|
19
19
|
minLength: 1,
|
|
@@ -148,7 +148,7 @@ export function registerGoalInterface(
|
|
|
148
148
|
pi.registerTool<typeof GoalUpdateParams, GoalUpdateDetails>({
|
|
149
149
|
name: GOAL_UPDATE_TOOL,
|
|
150
150
|
label: "Goal update",
|
|
151
|
-
description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it.",
|
|
151
|
+
description: "Record exactly one active-goal decision: complete after verification, continue with evidence and one next action, or audit the same blocker before blocking it. During an eligible later ordinary request for a blocked goal, only verified completion of the whole unchanged objective is permitted; it does not resume work or grant turns.",
|
|
152
152
|
parameters: GoalUpdateParams,
|
|
153
153
|
exposure: "model-only",
|
|
154
154
|
executionMode: "sequential",
|
|
@@ -157,14 +157,27 @@ export function registerGoalInterface(
|
|
|
157
157
|
if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
|
|
158
158
|
if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
|
|
159
159
|
const state = runtime.state;
|
|
160
|
-
if (!state || state.status !== "active") throw new Error("There is no active KillerOS goal to update");
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
160
|
+
if (!state || state.status !== "active" && state.status !== "blocked") throw new Error("There is no active KillerOS goal to update");
|
|
161
|
+
const generation = runtime.lifecycleGeneration;
|
|
162
|
+
const permission = runtime.blockedCompletion;
|
|
163
|
+
const ownsDecision = (): boolean => runtime.state === state
|
|
164
|
+
&& runtime.lifecycleGeneration === generation
|
|
165
|
+
&& (state.status === "blocked"
|
|
166
|
+
? permission?.kind === "eligible"
|
|
167
|
+
&& runtime.blockedCompletion === permission
|
|
168
|
+
&& permission.state === state
|
|
169
|
+
&& permission.generation === generation
|
|
170
|
+
: runtime.goalTurnInFlight
|
|
171
|
+
&& runtime.goalTurn?.turn === state.turns
|
|
172
|
+
&& runtime.goalTurn.revision === state.revision
|
|
173
|
+
&& state.turnDecision === undefined);
|
|
174
|
+
if (state.status === "blocked") {
|
|
175
|
+
if (!ownsDecision()) throw new Error("A blocked goal can only be completed during an eligible later ordinary request");
|
|
176
|
+
if (params.status !== "complete") throw new Error("A blocked goal accepts only complete; automatic continuation remains stopped");
|
|
177
|
+
if (state.turns === 0) throw new Error("A zero-turn blocked goal cannot record completion; no accounted goal turn exists");
|
|
178
|
+
} else {
|
|
179
|
+
if (state.turnDecision !== undefined) throw new Error("Only one goal decision may be accepted per logical goal turn");
|
|
180
|
+
if (!ownsDecision()) throw new Error("A goal decision can only be recorded during an active KillerOS goal turn");
|
|
168
181
|
}
|
|
169
182
|
if (params.status !== "complete" && params.status !== "continue" && params.status !== "blocked") {
|
|
170
183
|
throw new Error("Goal update status is invalid");
|
|
@@ -175,11 +188,7 @@ export function registerGoalInterface(
|
|
|
175
188
|
if (params.status === "complete") {
|
|
176
189
|
if (state.verification) await verifyGoalDeliverable(state.verification);
|
|
177
190
|
signal?.throwIfAborted();
|
|
178
|
-
if (
|
|
179
|
-
|| runtime.goalTurn?.turn !== state.turns
|
|
180
|
-
|| runtime.goalTurn.revision !== state.revision
|
|
181
|
-
|| state.turnDecision !== undefined
|
|
182
|
-
|| !runtime.goalTurnInFlight) throw new Error("Goal changed while completion was being verified");
|
|
191
|
+
if (!ownsDecision()) throw new Error("Goal changed while completion was being verified");
|
|
183
192
|
const verification: "file" | "model-reported" = state.verification ? "file" : "model-reported";
|
|
184
193
|
const decision = { kind: "complete" as const, turn: state.turns, evidence, verification };
|
|
185
194
|
try {
|
|
@@ -188,6 +197,7 @@ export function registerGoalInterface(
|
|
|
188
197
|
pauseGoalAfterFailure(pi, runtime, ctx, `goal completion could not be saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
189
198
|
throw error;
|
|
190
199
|
}
|
|
200
|
+
if (state.status === "blocked") runtime.blockedCompletion = { kind: "accepted", generation };
|
|
191
201
|
const text = state.verification
|
|
192
202
|
? `Goal verified complete at ${safeTerminalText(state.verification.path)}: ${evidence}`
|
|
193
203
|
: `Goal marked complete (model-reported): ${evidence}`;
|
|
@@ -197,6 +207,7 @@ export function registerGoalInterface(
|
|
|
197
207
|
};
|
|
198
208
|
}
|
|
199
209
|
|
|
210
|
+
if (state.status !== "active") throw new Error("A blocked goal accepts only complete");
|
|
200
211
|
if (params.status === "continue") {
|
|
201
212
|
if (typeof params.nextAction !== "string") throw new Error("A continue decision requires a nextAction");
|
|
202
213
|
const nextAction = normalizeGoalText(params.nextAction, GOAL_EVIDENCE_LIMIT, "Goal nextAction");
|
package/killeros/goal-runtime.ts
CHANGED
|
@@ -97,7 +97,11 @@ function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
|
|
100
|
-
|
|
100
|
+
const permission = runtime.blockedCompletion;
|
|
101
|
+
setGoalUpdateToolActive(pi, runtime.state?.status === "active"
|
|
102
|
+
|| permission?.kind === "eligible"
|
|
103
|
+
&& permission.state === runtime.state
|
|
104
|
+
&& permission.generation === runtime.lifecycleGeneration);
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
function goalUpdateToolIsAvailable(pi: ExtensionAPI): boolean {
|
|
@@ -119,6 +123,7 @@ export function persistGoalState(
|
|
|
119
123
|
const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
|
|
120
124
|
pi.appendEntry(GOAL_ENTRY_TYPE, data);
|
|
121
125
|
runtime.state = state;
|
|
126
|
+
runtime.blockedCompletion = undefined;
|
|
122
127
|
if (event === "clear") {
|
|
123
128
|
runtime.lifecycleGeneration += 1;
|
|
124
129
|
runtime.continuationHeld = undefined;
|
|
@@ -372,6 +377,21 @@ function goalInstructions(state: GoalState, heading: string): string {
|
|
|
372
377
|
].join("\n");
|
|
373
378
|
}
|
|
374
379
|
|
|
380
|
+
function blockedGoalSystemPrompt(state: Extract<GoalState, { status: "blocked" }>): string {
|
|
381
|
+
return [
|
|
382
|
+
"# Blocked KillerOS goal",
|
|
383
|
+
"Status: blocked",
|
|
384
|
+
"Objective:",
|
|
385
|
+
state.objective,
|
|
386
|
+
"",
|
|
387
|
+
"Treat the exact saved objective above as authoritative. Automatic goal continuation remains stopped; this ordinary request grants no goal turns.",
|
|
388
|
+
"If killeros_goal_update is available, you may call it only with status complete and current evidence after auditing and verifying the whole unchanged objective.",
|
|
389
|
+
"File proof checks only the persisted deliverable against its original baseline, not every acceptance criterion. General-objective completion is model-reported.",
|
|
390
|
+
"The disappearance of a blocker alone does not prove completion. If useful work remains or this request is unrelated, finish normally without a goal update. No decision is mandatory.",
|
|
391
|
+
"Never use the goal tool to continue, audit a blocker, pause, resume, edit, replace, or clear this goal. User controls remain unchanged.",
|
|
392
|
+
].join("\n");
|
|
393
|
+
}
|
|
394
|
+
|
|
375
395
|
function goalSystemPrompt(state: GoalState): string {
|
|
376
396
|
return goalInstructions(state, "Active KillerOS goal");
|
|
377
397
|
}
|
|
@@ -406,6 +426,7 @@ export function registerGoalRuntime(
|
|
|
406
426
|
const generation = runtime.lifecycleGeneration;
|
|
407
427
|
const restored = restoreGoalState(ctx);
|
|
408
428
|
runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
|
|
429
|
+
runtime.blockedCompletion = undefined;
|
|
409
430
|
syncGoalUpdateTool(pi, runtime);
|
|
410
431
|
runtime.continuationScheduled = false;
|
|
411
432
|
runtime.continuationHeld = undefined;
|
|
@@ -434,6 +455,7 @@ export function registerGoalRuntime(
|
|
|
434
455
|
|
|
435
456
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
436
457
|
runtime.lifecycleGeneration += 1;
|
|
458
|
+
runtime.blockedCompletion = undefined;
|
|
437
459
|
if (runtime.state?.status === "active") {
|
|
438
460
|
const checkpoint = checkpointActiveGoalState(runtime.state, Date.now());
|
|
439
461
|
try {
|
|
@@ -453,7 +475,13 @@ export function registerGoalRuntime(
|
|
|
453
475
|
runtime.continuationScheduled = false;
|
|
454
476
|
if (!runtime.goalTurnInFlight && isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return;
|
|
455
477
|
const current = runtime.state;
|
|
456
|
-
if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current
|
|
478
|
+
if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current) return;
|
|
479
|
+
if (current.status === "blocked") {
|
|
480
|
+
runtime.blockedCompletion ??= { kind: "eligible", state: current, generation: runtime.lifecycleGeneration };
|
|
481
|
+
syncGoalUpdateTool(pi, runtime);
|
|
482
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${blockedGoalSystemPrompt(current)}` };
|
|
483
|
+
}
|
|
484
|
+
if (current.status !== "active") return;
|
|
457
485
|
if (runtime.goalTurnInFlight) {
|
|
458
486
|
if (!goalUpdateToolIsAvailable(pi)) {
|
|
459
487
|
pauseGoalBeforeTurn(pi, runtime, ctx);
|
|
@@ -219,7 +219,9 @@ export function registerGoalSettlement(
|
|
|
219
219
|
pi: ExtensionAPI,
|
|
220
220
|
runtime: GoalRuntime,
|
|
221
221
|
): AutoCompactionGoalHandlers {
|
|
222
|
-
pi.on("agent_settled", (
|
|
222
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
223
|
+
runtime.blockedCompletion = undefined;
|
|
224
|
+
syncGoalUpdateTool(pi, runtime);
|
|
223
225
|
const wasGoalTurn = runtime.goalTurnInFlight;
|
|
224
226
|
const continuationWasScheduled = runtime.continuationScheduled;
|
|
225
227
|
const agentEndObserved = runtime.agentEndObserved;
|
|
@@ -228,6 +230,18 @@ export function registerGoalSettlement(
|
|
|
228
230
|
runtime.agentEndObserved = false;
|
|
229
231
|
runtime.continuationScheduled = false;
|
|
230
232
|
|
|
233
|
+
// ctx.compact() also aborts the request. An owned compaction must settle through
|
|
234
|
+
// its success/error callback; its cancellation cannot authorize recovery.
|
|
235
|
+
if ("aborted" in event && event.aborted === true && !runtime.automaticCompaction
|
|
236
|
+
&& runtime.lastStopReason !== "aborted") {
|
|
237
|
+
runtime.lastStopReason = undefined;
|
|
238
|
+
runtime.lastError = undefined;
|
|
239
|
+
if (runtime.state?.status === "active") {
|
|
240
|
+
pauseGoalAfterFailure(pi, runtime, ctx, "the agent request was cancelled");
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
231
245
|
if (runtime.automaticCompaction) {
|
|
232
246
|
const stopReason = runtime.lastStopReason;
|
|
233
247
|
const error = safeTerminalText(runtime.lastError ?? "");
|
|
@@ -311,6 +325,8 @@ export function registerGoalSettlement(
|
|
|
311
325
|
});
|
|
312
326
|
|
|
313
327
|
return {
|
|
328
|
+
hasAcceptedTerminalDecision: (): boolean => runtime.blockedCompletion?.kind === "accepted"
|
|
329
|
+
&& runtime.blockedCompletion.generation === runtime.lifecycleGeneration,
|
|
314
330
|
isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
|
|
315
331
|
&& isSavedSession(ctx)
|
|
316
332
|
&& runtime.state?.status === "active",
|
|
@@ -116,11 +116,11 @@ export function registerCompletionNotifications(
|
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
pi.on("agent_settled", (
|
|
119
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
120
120
|
if (!requestPending || ctx.mode !== "tui") return;
|
|
121
121
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
122
122
|
requestPending = false;
|
|
123
|
-
if (!enabled || lastStopReason === "aborted") return;
|
|
123
|
+
if (!enabled || "aborted" in event && event.aborted === true || lastStopReason === "aborted") return;
|
|
124
124
|
try {
|
|
125
125
|
runtime.ring();
|
|
126
126
|
} catch (error) {
|
|
@@ -325,7 +325,8 @@ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boo
|
|
|
325
325
|
if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
|
|
326
326
|
if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
|
|
327
327
|
const attributes = entry.eolAttributes;
|
|
328
|
-
|
|
328
|
+
// eol controls checkout bytes; text files still normalize CRLF when compared with the index.
|
|
329
|
+
if (attributes === "-text") return false;
|
|
329
330
|
const automatic = attributes.startsWith("text=auto") || !attributes;
|
|
330
331
|
if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
|
|
331
332
|
if (automatic) {
|
package/killeros/question-ui.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
Editor,
|
|
5
|
-
Markdown,
|
|
6
|
-
truncateToWidth,
|
|
7
|
-
visibleWidth,
|
|
8
|
-
wrapTextWithAnsi,
|
|
9
|
-
type EditorTheme,
|
|
10
|
-
} from "@earendil-works/pi-tui";
|
|
11
|
-
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
12
4
|
|
|
13
5
|
export interface DisplayOption {
|
|
14
6
|
label: string;
|
|
7
|
+
answer: string;
|
|
15
8
|
description?: string;
|
|
16
9
|
preview?: string;
|
|
17
10
|
originalIndex: number;
|
|
18
|
-
isOther: boolean;
|
|
19
11
|
}
|
|
20
12
|
|
|
21
13
|
export type QuestionSelection =
|
|
@@ -31,65 +23,12 @@ export const CUSTOM_INPUT_HISTORY_BYTES = 64 * 1024;
|
|
|
31
23
|
export const FILTER_QUERY_MAX_CHARACTERS = 4_000;
|
|
32
24
|
export const FILTER_QUERY_MAX_BYTES = 16_000;
|
|
33
25
|
|
|
34
|
-
function isPrintableInput(data: string): boolean {
|
|
35
|
-
return data.length > 0 && !/[\u0000-\u001F\u007F-\u009F]/u.test(data);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
39
|
-
|
|
40
|
-
function decodeQuestionFilterInput(data: string): string | undefined {
|
|
41
|
-
const kittyPrintable = decodeKittyPrintable(data);
|
|
42
|
-
if (kittyPrintable !== undefined) return isPrintableInput(kittyPrintable) ? kittyPrintable : undefined;
|
|
43
|
-
|
|
44
|
-
const pasteStart = "\x1B[200~";
|
|
45
|
-
const pasteEnd = "\x1B[201~";
|
|
46
|
-
const startIndex = data.indexOf(pasteStart);
|
|
47
|
-
const endIndex = data.indexOf(pasteEnd, startIndex + pasteStart.length);
|
|
48
|
-
if (startIndex >= 0 && endIndex >= 0) {
|
|
49
|
-
return data
|
|
50
|
-
.slice(startIndex + pasteStart.length, endIndex)
|
|
51
|
-
.replace(/\r\n|\r|\n/gu, "")
|
|
52
|
-
.replace(/\t/gu, " ")
|
|
53
|
-
.replace(/[\u0000-\u001F\u007F-\u009F]/gu, "");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return isPrintableInput(data) ? data : undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function removeLastGrapheme(value: string): string {
|
|
60
|
-
const segments = [...graphemeSegmenter.segment(value)];
|
|
61
|
-
const last = segments.at(-1);
|
|
62
|
-
return last ? value.slice(0, last.index) : "";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function inputCharacterCount(value: string): number {
|
|
66
|
-
let count = 0;
|
|
67
|
-
for (const _character of value) count += 1;
|
|
68
|
-
return count;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
26
|
export function oneLine(value: string): string {
|
|
72
27
|
return value.replace(/\s+/gu, " ").trim();
|
|
73
28
|
}
|
|
74
29
|
|
|
75
|
-
function
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function visibleOptionRange(total: number, selected: number, capacity: number): { start: number; end: number } {
|
|
80
|
-
const size = Math.max(1, Math.min(total, capacity));
|
|
81
|
-
const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
|
|
82
|
-
return { start, end: Math.min(total, start + size) };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function boundedQuestionLines(question: string, width: number, rowLimit: number): string[] {
|
|
86
|
-
const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
|
|
87
|
-
if (wrapped.length <= rowLimit) return wrapped;
|
|
88
|
-
const visible = wrapped.slice(0, rowLimit);
|
|
89
|
-
const finalIndex = rowLimit - 1;
|
|
90
|
-
const finalLine = visible[finalIndex];
|
|
91
|
-
if (finalLine !== undefined) visible[finalIndex] = truncateToWidth(finalLine, width, "…");
|
|
92
|
-
return visible;
|
|
30
|
+
function inputCharacterCount(value: string): number {
|
|
31
|
+
return [...value].length;
|
|
93
32
|
}
|
|
94
33
|
|
|
95
34
|
function compactMultipleAnswers(answers: readonly string[], width: number): string {
|
|
@@ -139,452 +78,192 @@ export class MultipleResultText {
|
|
|
139
78
|
invalidate(): void {}
|
|
140
79
|
}
|
|
141
80
|
|
|
81
|
+
type Choice<T> = { label: string; value: T };
|
|
82
|
+
type DialogSize = { width: number; rows: number };
|
|
83
|
+
type MainAction =
|
|
84
|
+
| { kind: "option"; option: DisplayOption }
|
|
85
|
+
| { kind: "custom" }
|
|
86
|
+
| { kind: "submit" }
|
|
87
|
+
| { kind: "actions" };
|
|
88
|
+
type MoreAction = "filter" | "clear" | "history" | "remove" | "review" | "question";
|
|
89
|
+
class QuestionAborted extends Error {}
|
|
90
|
+
|
|
91
|
+
function dialogSize(): DialogSize {
|
|
92
|
+
return { width: Math.max(1, (process.stdout.columns || 80) - 6), rows: process.stdout.rows || 24 };
|
|
93
|
+
}
|
|
94
|
+
|
|
142
95
|
export async function openQuestionUi(config: {
|
|
143
96
|
ctx: ExtensionContext;
|
|
144
|
-
signal
|
|
97
|
+
signal: AbortSignal;
|
|
145
98
|
question: string;
|
|
146
99
|
options: DisplayOption[];
|
|
147
|
-
originalOptions: readonly { label: string }[];
|
|
148
100
|
mode: "single" | "multiple";
|
|
149
101
|
minSelections: number;
|
|
150
102
|
maxSelections: number;
|
|
151
103
|
customInputHistory: readonly string[];
|
|
152
104
|
rememberCustomInput: (value: string) => boolean;
|
|
153
105
|
}): Promise<QuestionSelection> {
|
|
154
|
-
const {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
let cachedRows: number | undefined;
|
|
177
|
-
let cachedLines: string[] | undefined;
|
|
178
|
-
let completed = false;
|
|
179
|
-
|
|
180
|
-
const finish = (selection: QuestionSelection): void => {
|
|
181
|
-
if (completed) return;
|
|
182
|
-
completed = true;
|
|
183
|
-
done(selection);
|
|
184
|
-
};
|
|
185
|
-
finishFromAbort = () => finish({ kind: "aborted" });
|
|
186
|
-
|
|
187
|
-
const keyHint = (keybinding: Parameters<typeof keybindings.getKeys>[0], description: string): string => {
|
|
188
|
-
const keyText = keybindings.getKeys(keybinding)
|
|
189
|
-
.join("/")
|
|
190
|
-
.split("/")
|
|
191
|
-
.map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part).join("+"))
|
|
192
|
-
.join("/");
|
|
193
|
-
return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
const editorTheme: EditorTheme = {
|
|
197
|
-
borderColor: (text) => theme.fg("accent", text),
|
|
198
|
-
selectList: {
|
|
199
|
-
selectedPrefix: (text) => theme.fg("accent", text),
|
|
200
|
-
selectedText: (text) => theme.fg("accent", text),
|
|
201
|
-
description: (text) => theme.fg("muted", text),
|
|
202
|
-
scrollInfo: (text) => theme.fg("dim", text),
|
|
203
|
-
noMatch: (text) => theme.fg("warning", text),
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
const editor = new Editor(tui, editorTheme);
|
|
207
|
-
customInputHistory.forEach((value) => editor.addToHistory(value));
|
|
208
|
-
|
|
209
|
-
const filteredOptions = (): DisplayOption[] => {
|
|
210
|
-
const query = filterQuery.trim().toLowerCase();
|
|
211
|
-
return options.filter((option) => option.isOther
|
|
212
|
-
|| query.length === 0
|
|
213
|
-
|| option.label.toLowerCase().includes(query)
|
|
214
|
-
|| option.description?.toLowerCase().includes(query));
|
|
215
|
-
};
|
|
216
|
-
const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
|
|
217
|
-
const orderedMultipleSelection = () => {
|
|
218
|
-
const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
|
|
219
|
-
const predefined = selectedIndices.map((index) => {
|
|
220
|
-
const option = originalOptions[index - 1];
|
|
221
|
-
if (!option) throw new Error("Question selection no longer matches an available option");
|
|
222
|
-
return option.label;
|
|
223
|
-
});
|
|
224
|
-
return {
|
|
225
|
-
answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
|
|
226
|
-
selectedIndices,
|
|
227
|
-
...(customAnswer === undefined ? {} : { customAnswer }),
|
|
228
|
-
};
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const invalidate = (): void => {
|
|
232
|
-
cachedWidth = undefined;
|
|
233
|
-
cachedRows = undefined;
|
|
234
|
-
cachedLines = undefined;
|
|
235
|
-
editor.invalidate();
|
|
236
|
-
};
|
|
237
|
-
const refresh = (): void => {
|
|
238
|
-
invalidate();
|
|
239
|
-
tui.requestRender();
|
|
240
|
-
};
|
|
241
|
-
const notifyFilterLimit = (): void => ctx.ui.notify(
|
|
242
|
-
`Question filters are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes`,
|
|
243
|
-
"error",
|
|
244
|
-
);
|
|
245
|
-
const appendFilterInput = (value: string): void => {
|
|
246
|
-
const next = filterQuery + value;
|
|
247
|
-
if (inputCharacterCount(next) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(next, "utf8") > FILTER_QUERY_MAX_BYTES) {
|
|
248
|
-
notifyFilterLimit();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
filterQuery = next;
|
|
252
|
-
optionIndex = 0;
|
|
253
|
-
refresh();
|
|
254
|
-
};
|
|
255
|
-
const togglePredefined = (option: DisplayOption): void => {
|
|
256
|
-
if (selectedOriginalIndices.has(option.originalIndex)) {
|
|
257
|
-
selectedOriginalIndices.delete(option.originalIndex);
|
|
258
|
-
refresh();
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (selectedCount() >= maxSelections) {
|
|
262
|
-
ctx.ui.notify(`Select at most ${maxSelections} answer${maxSelections === 1 ? "" : "s"}`, "error");
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
selectedOriginalIndices.add(option.originalIndex);
|
|
266
|
-
refresh();
|
|
267
|
-
};
|
|
268
|
-
const enterCustomMode = (): void => {
|
|
269
|
-
editMode = "custom";
|
|
270
|
-
editor.setText(mode === "multiple" ? customAnswer ?? "" : "");
|
|
271
|
-
refresh();
|
|
272
|
-
};
|
|
273
|
-
const enterFilterMode = (): void => {
|
|
274
|
-
editMode = "filter";
|
|
275
|
-
editor.setText(filterQuery);
|
|
276
|
-
refresh();
|
|
277
|
-
};
|
|
106
|
+
const { ctx, signal, question, options, mode, minSelections, maxSelections, customInputHistory, rememberCustomInput } = config;
|
|
107
|
+
const selected = new Set<number>();
|
|
108
|
+
let customAnswer: string | undefined;
|
|
109
|
+
let filter = "";
|
|
110
|
+
const checkAbort = (): void => { if (signal.aborted) throw new QuestionAborted(); };
|
|
111
|
+
const count = (): number => selected.size + (customAnswer === undefined ? 0 : 1);
|
|
112
|
+
const notifyMaximum = (): void => ctx.ui.notify(`Select at most ${maxSelections} answer${maxSelections === 1 ? "" : "s"}`, "error");
|
|
113
|
+
|
|
114
|
+
// Resize dismisses only this native step. Rebuild its pages without losing question state.
|
|
115
|
+
async function nativeRequest(request: (stepSignal: AbortSignal) => Promise<string | undefined>) {
|
|
116
|
+
checkAbort();
|
|
117
|
+
const resize = new AbortController();
|
|
118
|
+
const onResize = (): void => resize.abort();
|
|
119
|
+
process.stdout.on("resize", onResize);
|
|
120
|
+
try {
|
|
121
|
+
const value = await request(AbortSignal.any([signal, resize.signal]));
|
|
122
|
+
checkAbort();
|
|
123
|
+
return resize.signal.aborted ? { kind: "resize" as const } : { kind: "result" as const, value };
|
|
124
|
+
} finally {
|
|
125
|
+
process.stdout.off("resize", onResize);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
278
128
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
|
|
305
|
-
refresh();
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
customAnswer = answer;
|
|
309
|
-
editMode = "none";
|
|
310
|
-
editor.setText("");
|
|
311
|
-
refresh();
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
if (!rememberCustomInput(answer)) {
|
|
315
|
-
ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
finish({ kind: "custom", answer });
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
editMode = "none";
|
|
322
|
-
editor.setText("");
|
|
323
|
-
refresh();
|
|
324
|
-
};
|
|
129
|
+
async function choose<T>(title: string | ((size: DialogSize) => string), entries: Choice<T>[]): Promise<T | undefined> {
|
|
130
|
+
let start = 0;
|
|
131
|
+
while (true) {
|
|
132
|
+
const size = dialogSize();
|
|
133
|
+
const text = typeof title === "string" ? title.split("\n").slice(0, 2).map((line) => truncateToWidth(oneLine(line), size.width, "…")).join("\n") : title(size);
|
|
134
|
+
// Reserve native borders, hints, header/footer, and both page controls.
|
|
135
|
+
const capacity = Math.max(1, size.rows - 13 - text.split("\n").length);
|
|
136
|
+
start = Math.min(start, Math.max(0, entries.length - 1));
|
|
137
|
+
const page = entries.slice(start, start + capacity);
|
|
138
|
+
const labels = page.map((entry, index) => truncateToWidth(`${start + index + 1}. ${oneLine(entry.label)}`, size.width, "…"));
|
|
139
|
+
const next = "Next choices";
|
|
140
|
+
const previous = "Previous choices";
|
|
141
|
+
const offered = [...labels, ...(start + capacity < entries.length ? [next] : []), ...(start > 0 ? [previous] : [])];
|
|
142
|
+
const response = await nativeRequest((stepSignal) => ctx.ui.select(text, offered, { signal: stepSignal }));
|
|
143
|
+
checkAbort();
|
|
144
|
+
if (response.kind === "resize") continue;
|
|
145
|
+
if (response.value === undefined) return undefined;
|
|
146
|
+
if (response.value === next && offered.includes(next)) { start += capacity; continue; }
|
|
147
|
+
if (response.value === previous && offered.includes(previous)) { start = Math.max(0, start - capacity); continue; }
|
|
148
|
+
const index = labels.indexOf(response.value);
|
|
149
|
+
const entry = page[index];
|
|
150
|
+
if (!entry) throw new Error("Native question selection does not match an offered choice");
|
|
151
|
+
return entry.value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
325
154
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
editor.handleInput(data);
|
|
335
|
-
const after = editor.getExpandedText();
|
|
336
|
-
const overLimit = editMode === "filter"
|
|
337
|
-
? inputCharacterCount(after) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(after, "utf8") > FILTER_QUERY_MAX_BYTES
|
|
338
|
-
: inputCharacterCount(after) > CUSTOM_INPUT_MAX_CHARACTERS;
|
|
339
|
-
if (overLimit) {
|
|
340
|
-
editor.setText(before);
|
|
341
|
-
if (editMode === "filter") notifyFilterLimit();
|
|
342
|
-
else ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
|
|
343
|
-
}
|
|
344
|
-
refresh();
|
|
345
|
-
};
|
|
155
|
+
async function input(title: string, placeholder = ""): Promise<string | undefined> {
|
|
156
|
+
checkAbort();
|
|
157
|
+
const size = dialogSize();
|
|
158
|
+
// Pi reflows native input on resize. Keep its component and in-progress draft.
|
|
159
|
+
const value = await ctx.ui.input(truncateToWidth(title, size.width, "…"), truncateToWidth(oneLine(placeholder), size.width, "…"), { signal });
|
|
160
|
+
checkAbort();
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
346
163
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
164
|
+
async function review(content: string): Promise<void> {
|
|
165
|
+
let page = 0;
|
|
166
|
+
let pages = 1;
|
|
167
|
+
while (true) {
|
|
168
|
+
const action = await choose<"next" | "previous" | "back">((size) => {
|
|
169
|
+
const lines = wrapTextWithAnsi(content, size.width);
|
|
170
|
+
const capacity = Math.max(1, size.rows - 18);
|
|
171
|
+
pages = Math.max(1, Math.ceil(lines.length / capacity));
|
|
172
|
+
page = Math.min(page, pages - 1);
|
|
173
|
+
return [`Review ${page + 1}/${pages}`, ...lines.slice(page * capacity, (page + 1) * capacity)].join("\n");
|
|
174
|
+
}, [ { label: "Next page", value: "next" }, { label: "Previous page", value: "previous" }, { label: "Back", value: "back" } ]);
|
|
175
|
+
if (action === undefined || action === "back") return;
|
|
176
|
+
if (action === "next") page = Math.min(page + 1, pages - 1);
|
|
177
|
+
else page = Math.max(0, page - 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
352
180
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
367
|
-
optionIndex = Math.max(0, optionIndex - pageSize);
|
|
368
|
-
refresh();
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
372
|
-
optionIndex = Math.min(visibleOptions.length - 1, optionIndex + pageSize);
|
|
373
|
-
refresh();
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
181
|
+
function acceptCustom(raw: string): QuestionSelection | undefined {
|
|
182
|
+
checkAbort();
|
|
183
|
+
const answer = safeTerminalText(raw).trim();
|
|
184
|
+
if (!answer) { ctx.ui.notify("Enter a nonempty custom answer", "error"); return; }
|
|
185
|
+
if (inputCharacterCount(raw.trim()) > CUSTOM_INPUT_MAX_CHARACTERS) {
|
|
186
|
+
ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()} characters`, "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (mode === "multiple" && customAnswer === undefined && count() >= maxSelections) { notifyMaximum(); return; }
|
|
190
|
+
if (!rememberCustomInput(answer)) { ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error"); return; }
|
|
191
|
+
if (mode === "single") return { kind: "custom", answer };
|
|
192
|
+
customAnswer = answer;
|
|
193
|
+
}
|
|
376
194
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
195
|
+
try {
|
|
196
|
+
while (true) {
|
|
197
|
+
checkAbort();
|
|
198
|
+
const query = filter.trim().toLowerCase();
|
|
199
|
+
const visible = options.filter((option) => !query || option.label.toLowerCase().includes(query) || option.description?.toLowerCase().includes(query));
|
|
200
|
+
const entries: Choice<MainAction>[] = visible.map((option) => ({
|
|
201
|
+
label: `${mode === "multiple" ? selected.has(option.originalIndex) ? "[x] " : "[ ] " : ""}${option.label}${option.description ? ` · ${oneLine(option.description)}` : ""}`,
|
|
202
|
+
value: { kind: "option", option },
|
|
203
|
+
}));
|
|
204
|
+
if (mode === "multiple") entries.push({ label: "Submit answers", value: { kind: "submit" } });
|
|
205
|
+
entries.push({ label: customAnswer === undefined ? "Custom answer" : "Edit custom answer", value: { kind: "custom" } }, { label: "More actions", value: { kind: "actions" } });
|
|
206
|
+
const summary = mode === "multiple" ? `Selected ${count()} · choose ${minSelections}–${maxSelections}` : "Choose one answer";
|
|
207
|
+
const action = await choose(`${summary}${filter ? " · filtered" : ""}\n${question}`, entries);
|
|
208
|
+
checkAbort();
|
|
209
|
+
if (action === undefined) return { kind: "cancelled" };
|
|
210
|
+
switch (action.kind) {
|
|
211
|
+
case "option":
|
|
212
|
+
if (mode === "single") return { kind: "selected", answer: action.option.answer, originalIndex: action.option.originalIndex };
|
|
213
|
+
if (selected.has(action.option.originalIndex)) selected.delete(action.option.originalIndex);
|
|
214
|
+
else if (count() >= maxSelections) notifyMaximum();
|
|
215
|
+
else selected.add(action.option.originalIndex);
|
|
216
|
+
break;
|
|
217
|
+
case "submit": {
|
|
218
|
+
if (count() < minSelections) { ctx.ui.notify(`Select at least ${minSelections} answer${minSelections === 1 ? "" : "s"}`, "error"); break; }
|
|
219
|
+
const ordered = options.filter((option) => selected.has(option.originalIndex));
|
|
220
|
+
return { kind: "multiple", answers: [...ordered.map((option) => option.answer), ...(customAnswer === undefined ? [] : [customAnswer])], selectedIndices: ordered.map((option) => option.originalIndex), ...(customAnswer === undefined ? {} : { customAnswer }) };
|
|
221
|
+
}
|
|
222
|
+
case "custom": {
|
|
223
|
+
const raw = await input("Custom answer", customAnswer);
|
|
224
|
+
if (raw !== undefined) { const result = acceptCustom(raw); if (result) return result; }
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "actions": {
|
|
228
|
+
const more: Choice<MoreAction>[] = [ { label: "Filter options", value: "filter" }, { label: "Review question", value: "question" }, { label: "Review option details", value: "review" } ];
|
|
229
|
+
if (filter) more.push({ label: "Clear filter", value: "clear" });
|
|
230
|
+
if (customInputHistory.length > 0) more.push({ label: "Recent answers", value: "history" });
|
|
231
|
+
if (customAnswer !== undefined) more.push({ label: "Remove custom answer", value: "remove" });
|
|
232
|
+
const extra = await choose("More actions", more);
|
|
233
|
+
checkAbort();
|
|
234
|
+
switch (extra) {
|
|
235
|
+
case undefined: break;
|
|
236
|
+
case "clear": filter = ""; break;
|
|
237
|
+
case "remove": customAnswer = undefined; break;
|
|
238
|
+
case "question": await review(question); break;
|
|
239
|
+
case "review": {
|
|
240
|
+
const option = await choose("Review option details", options.map((option) => ({ label: option.label, value: option })));
|
|
241
|
+
if (option) await review([option.label, option.description, option.preview].filter((value) => value !== undefined).join("\n\n"));
|
|
242
|
+
break;
|
|
401
243
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
ctx.ui.notify(`
|
|
407
|
-
|
|
408
|
-
finish({ kind: "multiple", ...orderedMultipleSelection() });
|
|
244
|
+
case "filter": {
|
|
245
|
+
const raw = await input("Filter options", filter);
|
|
246
|
+
checkAbort();
|
|
247
|
+
if (raw !== undefined) {
|
|
248
|
+
if (inputCharacterCount(raw) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(raw, "utf8") > FILTER_QUERY_MAX_BYTES) ctx.ui.notify(`Question filters are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes`, "error");
|
|
249
|
+
else filter = safeTerminalText(raw);
|
|
409
250
|
}
|
|
410
|
-
|
|
251
|
+
break;
|
|
411
252
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
refresh();
|
|
417
|
-
} else finish({ kind: "cancelled" });
|
|
418
|
-
return;
|
|
253
|
+
case "history": {
|
|
254
|
+
const answer = await choose("Recent answers", [...customInputHistory].reverse().map((answer) => ({ label: answer, value: answer })));
|
|
255
|
+
if (answer !== undefined) { const result = acceptCustom(answer); if (result) return result; }
|
|
256
|
+
break;
|
|
419
257
|
}
|
|
420
|
-
return;
|
|
258
|
+
default: { const exhaustive: never = extra; return exhaustive; }
|
|
421
259
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!selected) return;
|
|
426
|
-
if (selected.isOther) enterCustomMode();
|
|
427
|
-
else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
431
|
-
if (filterQuery) {
|
|
432
|
-
filterQuery = "";
|
|
433
|
-
optionIndex = 0;
|
|
434
|
-
refresh();
|
|
435
|
-
} else finish({ kind: "cancelled" });
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
if (keybindings.matches(data, "tui.editor.deleteCharBackward")) {
|
|
439
|
-
if (filterQuery) {
|
|
440
|
-
filterQuery = removeLastGrapheme(filterQuery);
|
|
441
|
-
optionIndex = 0;
|
|
442
|
-
refresh();
|
|
443
|
-
}
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
if (!isPasteInput && printableInput && /^[1-9]$/u.test(printableInput)) {
|
|
447
|
-
const selected = visibleOptions[Number(printableInput) - 1];
|
|
448
|
-
if (!selected) return;
|
|
449
|
-
if (selected.isOther) enterCustomMode();
|
|
450
|
-
else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
if (printableInput) appendFilterInput(printableInput);
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const render = (width: number): string[] => {
|
|
457
|
-
if (width <= 0) return [];
|
|
458
|
-
const rowBudget = tui.terminal.rows;
|
|
459
|
-
if (rowBudget <= 0) return [];
|
|
460
|
-
if (cachedLines && cachedWidth === width && cachedRows === rowBudget) return cachedLines;
|
|
461
|
-
const visibleOptions = filteredOptions();
|
|
462
|
-
if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
|
|
463
|
-
const selected = visibleOptions[optionIndex];
|
|
464
|
-
const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
|
|
465
|
-
const editorText = editor.getExpandedText();
|
|
466
|
-
const editorCount = inputCharacterCount(editorText).toLocaleString();
|
|
467
|
-
const filterCount = inputCharacterCount(editMode === "filter" ? editorText : filterQuery).toLocaleString();
|
|
468
|
-
const compactDraft = editorText.replace(/\r\n|\r|\n/gu, " ↵ ") || (editMode === "filter" ? "Type a filter" : "Type an answer");
|
|
469
|
-
const selectionRange = minSelections === maxSelections ? `${minSelections}` : `${minSelections}–${maxSelections}`;
|
|
470
|
-
const selectionStatus = `Selected ${selectedCount()} · required ${selectionRange}`;
|
|
471
|
-
const optionLabel = (option: DisplayOption, index: number): string => {
|
|
472
|
-
if (mode === "single") return `${index === optionIndex ? ">" : " "} ${index + 1}. ${option.label}`;
|
|
473
|
-
const checked = option.isOther ? customAnswer !== undefined : selectedOriginalIndices.has(option.originalIndex);
|
|
474
|
-
const label = option.isOther && customAnswer !== undefined ? `Custom: ${oneLine(customAnswer)}` : option.label;
|
|
475
|
-
return `${index === optionIndex ? ">" : " "} ${checked ? "[x]" : "[ ]"} ${index + 1}. ${label}`;
|
|
476
|
-
};
|
|
477
|
-
const browseHint = mode === "multiple"
|
|
478
|
-
? `space toggle • / filter • ${keyHint("tui.select.confirm", selected?.isOther ? (customAnswer ? "edit custom" : "add custom") : "submit")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`
|
|
479
|
-
: `${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`;
|
|
480
|
-
const editHint = `${keyHint("tui.input.submit", editMode === "filter" ? "apply" : "submit")} • ${keyHint("tui.select.cancel", "options")}`;
|
|
481
|
-
|
|
482
|
-
let lines: string[];
|
|
483
|
-
if (rowBudget <= 2) {
|
|
484
|
-
if (editMode !== "none") lines = [`${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft];
|
|
485
|
-
else if (mode === "multiple") {
|
|
486
|
-
const focusedRow = selected ? optionLabel(selected, optionIndex) : "";
|
|
487
|
-
const compactSelectionStatus = visibleWidth(selectionStatus) <= width
|
|
488
|
-
? selectionStatus
|
|
489
|
-
: `Selected ${selectedCount()}`;
|
|
490
|
-
lines = focusedRow && visibleWidth(focusedRow) <= width
|
|
491
|
-
? [focusedRow, compactSelectionStatus]
|
|
492
|
-
: [compactSelectionStatus, focusedRow];
|
|
493
|
-
} else lines = [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
|
|
494
|
-
} else if (rowBudget <= 5) {
|
|
495
|
-
lines = [
|
|
496
|
-
...boundedQuestionLines(question, width, Math.max(1, rowBudget - 3)),
|
|
497
|
-
editMode !== "none"
|
|
498
|
-
? `${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
499
|
-
: selected ? optionLabel(selected, optionIndex) : "No matching options",
|
|
500
|
-
editMode !== "none" ? compactDraft : mode === "multiple" ? selectionStatus : filterQuery ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}` : position,
|
|
501
|
-
editMode !== "none" ? editHint : browseHint,
|
|
502
|
-
];
|
|
503
|
-
} else {
|
|
504
|
-
const questionLines = boundedQuestionLines(question, width, Math.max(1, rowBudget - 5));
|
|
505
|
-
const contentRows = rowBudget - questionLines.length - 4;
|
|
506
|
-
const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
|
|
507
|
-
const detailCapacity = Math.max(0, contentRows - optionCapacity);
|
|
508
|
-
const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
|
|
509
|
-
const hiddenAbove = start > 0 ? `↑ ${start}` : "";
|
|
510
|
-
const hiddenBelowCount = visibleOptions.length - end;
|
|
511
|
-
const hiddenBelow = hiddenBelowCount > 0 ? `↓ ${hiddenBelowCount}` : "";
|
|
512
|
-
const hiddenStatus = [hiddenAbove, hiddenBelow].filter(Boolean).join(" · ");
|
|
513
|
-
const progress = editMode === "filter"
|
|
514
|
-
? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
|
|
515
|
-
: editMode === "custom"
|
|
516
|
-
? `Answer ${editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
517
|
-
: mode === "multiple"
|
|
518
|
-
? `${selectionStatus}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`
|
|
519
|
-
: filterQuery ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} · ${position}` : `${position}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`;
|
|
520
|
-
const navigationHint = editMode !== "none" ? editHint : mode === "multiple"
|
|
521
|
-
? `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${browseHint}`
|
|
522
|
-
: `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
|
|
523
|
-
lines = [
|
|
524
|
-
theme.fg("accent", "─".repeat(width)),
|
|
525
|
-
...questionLines.map((line) => theme.fg("text", line)),
|
|
526
|
-
theme.fg("muted", truncateToWidth(` ${progress}`, width, "…")),
|
|
527
|
-
];
|
|
528
|
-
if (editMode !== "none") {
|
|
529
|
-
const editorLines = editor.render(width);
|
|
530
|
-
const draftLines = editorLines.length > 2 ? editorLines.slice(1, -1) : editorLines;
|
|
531
|
-
lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
|
|
532
|
-
} else {
|
|
533
|
-
for (let index = start; index < end; index += 1) {
|
|
534
|
-
const option = visibleOptions[index];
|
|
535
|
-
if (!option) continue;
|
|
536
|
-
const color: ThemeColor = index === optionIndex ? "accent" : "text";
|
|
537
|
-
lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
|
|
538
|
-
}
|
|
539
|
-
const detailLines: string[] = [];
|
|
540
|
-
if (selected?.description) detailLines.push(...wrapTextWithAnsi(theme.fg("muted", selected.description), width));
|
|
541
|
-
if (selected?.preview) {
|
|
542
|
-
detailLines.push(theme.fg("accent", theme.bold("Proposal preview")));
|
|
543
|
-
detailLines.push(...new Markdown(selected.preview, 0, 0, {
|
|
544
|
-
heading: (text) => theme.fg("accent", theme.bold(text)), link: (text) => theme.fg("accent", text),
|
|
545
|
-
linkUrl: (text) => theme.fg("dim", text), code: (text) => theme.fg("mdCode", text),
|
|
546
|
-
codeBlock: (text) => theme.fg("mdCodeBlock", text), codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
|
|
547
|
-
quote: (text) => theme.fg("mdQuote", text), quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
|
|
548
|
-
hr: (text) => theme.fg("mdHr", text), listBullet: (text) => theme.fg("mdListBullet", text),
|
|
549
|
-
bold: (text) => theme.bold(text), italic: (text) => theme.italic(text), strikethrough: (text) => theme.strikethrough(text),
|
|
550
|
-
underline: (text) => theme.underline(text),
|
|
551
|
-
}, { color: (text) => theme.fg("muted", text) }).render(width));
|
|
552
|
-
}
|
|
553
|
-
if (detailCapacity > 0 && detailLines.length > detailCapacity) {
|
|
554
|
-
const visibleDetailRows = Math.max(0, detailCapacity - 1);
|
|
555
|
-
lines.push(...detailLines.slice(0, visibleDetailRows));
|
|
556
|
-
const hiddenRows = detailLines.length - visibleDetailRows;
|
|
557
|
-
lines.push(theme.fg("dim", `… ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
|
|
558
|
-
} else lines.push(...detailLines.slice(0, detailCapacity));
|
|
559
|
-
}
|
|
560
|
-
lines.push(theme.fg("dim", truncateToWidth(` ${navigationHint}`, width, "…")));
|
|
561
|
-
lines.push(theme.fg("accent", "─".repeat(width)));
|
|
562
|
-
}
|
|
563
|
-
cachedWidth = width;
|
|
564
|
-
cachedRows = rowBudget;
|
|
565
|
-
cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, width, rowBudget <= 5 ? "…" : ""));
|
|
566
|
-
return cachedLines;
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
let focused = false;
|
|
570
|
-
return {
|
|
571
|
-
get focused(): boolean { return focused; },
|
|
572
|
-
set focused(value: boolean) { focused = value; editor.focused = value; },
|
|
573
|
-
render,
|
|
574
|
-
handleInput,
|
|
575
|
-
invalidate,
|
|
576
|
-
};
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
const abortHandler = (): void => finishFromAbort?.();
|
|
580
|
-
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
581
|
-
if (signal?.aborted) abortHandler();
|
|
582
|
-
let result: QuestionSelection;
|
|
583
|
-
try {
|
|
584
|
-
result = await resultPromise;
|
|
585
|
-
} finally {
|
|
586
|
-
signal?.removeEventListener("abort", abortHandler);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
default: { const exhaustive: never = action; return exhaustive; }
|
|
587
263
|
}
|
|
588
|
-
|
|
589
|
-
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof QuestionAborted) return { kind: "aborted" };
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
590
269
|
}
|
package/killeros/question.ts
CHANGED
|
@@ -87,8 +87,10 @@ type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
|
|
|
87
87
|
|
|
88
88
|
export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
89
89
|
const customInputHistory: string[] = [];
|
|
90
|
+
const pendingQuestions = new Set<AbortController>();
|
|
90
91
|
let customInputHistoryBytes = 0;
|
|
91
92
|
const clearCustomInputHistory = (): void => {
|
|
93
|
+
for (const pending of pendingQuestions) pending.abort();
|
|
92
94
|
customInputHistory.length = 0;
|
|
93
95
|
customInputHistoryBytes = 0;
|
|
94
96
|
};
|
|
@@ -132,32 +134,37 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
132
134
|
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
133
135
|
|
|
134
136
|
const question = safeTerminalText(params.question);
|
|
135
|
-
const options: DisplayOption[] =
|
|
136
|
-
...params.options.map((option, index) => ({
|
|
137
|
+
const options: DisplayOption[] = params.options.map((option, index) => ({
|
|
137
138
|
label: safeTerminalText(option.label),
|
|
139
|
+
answer: option.label,
|
|
138
140
|
description: option.description === undefined ? undefined : safeTerminalText(option.description),
|
|
139
141
|
preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
|
|
140
142
|
originalIndex: index + 1,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const pending = new AbortController();
|
|
146
|
+
pendingQuestions.add(pending);
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await openQuestionUi({
|
|
150
|
+
ctx,
|
|
151
|
+
signal: signal ? AbortSignal.any([signal, pending.signal]) : pending.signal,
|
|
152
|
+
question,
|
|
153
|
+
options,
|
|
154
|
+
mode,
|
|
155
|
+
minSelections,
|
|
156
|
+
maxSelections,
|
|
157
|
+
customInputHistory,
|
|
158
|
+
rememberCustomInput,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
pendingQuestions.delete(pending);
|
|
162
|
+
}
|
|
158
163
|
|
|
159
164
|
const simpleOptions = params.options.map((option) => option.label);
|
|
160
|
-
if (result.kind === "aborted"
|
|
165
|
+
if (result.kind === "aborted" || pending.signal.aborted || signal?.aborted) {
|
|
166
|
+
throw new Error("Question cancelled because the agent operation was aborted");
|
|
167
|
+
}
|
|
161
168
|
if (result.kind === "cancelled" && mode === "multiple") {
|
|
162
169
|
return {
|
|
163
170
|
content: [{ type: "text", text: "User cancelled the question" }],
|
package/killeros/runtime.ts
CHANGED
|
@@ -121,6 +121,10 @@ export interface GoalTurnExecution {
|
|
|
121
121
|
revision: number;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
export type BlockedGoalCompletion =
|
|
125
|
+
| { kind: "eligible"; state: Extract<GoalState, { status: "blocked" }>; generation: number }
|
|
126
|
+
| { kind: "accepted"; generation: number };
|
|
127
|
+
|
|
124
128
|
export interface GoalRuntime {
|
|
125
129
|
state?: GoalState;
|
|
126
130
|
continuationScheduled: boolean;
|
|
@@ -128,6 +132,7 @@ export interface GoalRuntime {
|
|
|
128
132
|
goalTurnInFlight: boolean;
|
|
129
133
|
agentEndObserved: boolean;
|
|
130
134
|
goalTurn?: GoalTurnExecution;
|
|
135
|
+
blockedCompletion?: BlockedGoalCompletion;
|
|
131
136
|
automaticCompaction?: AutomaticGoalCompaction;
|
|
132
137
|
persistenceRetryNeeded: boolean;
|
|
133
138
|
lastStopReason?: string;
|
package/killeros/worked-for.ts
CHANGED
|
@@ -454,7 +454,7 @@ export function registerWorkedFor(
|
|
|
454
454
|
}
|
|
455
455
|
});
|
|
456
456
|
|
|
457
|
-
pi.on("agent_settled", async (
|
|
457
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
458
458
|
if (!active || ctx.mode !== "tui") return;
|
|
459
459
|
const settled = active;
|
|
460
460
|
const generation = lifecycleGeneration;
|
|
@@ -469,7 +469,7 @@ export function registerWorkedFor(
|
|
|
469
469
|
ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
|
|
470
470
|
}
|
|
471
471
|
const settledTokens = sessionTokenTotal(ctx);
|
|
472
|
-
const outcome = workedForOutcome(settled.stopReason);
|
|
472
|
+
const outcome = "aborted" in event && event.aborted === true ? "stopped" : workedForOutcome(settled.stopReason);
|
|
473
473
|
const throughput = outcome === "done" && !settled.throughputInvalid && settled.responseStartedAt === undefined
|
|
474
474
|
&& positiveInteger(settled.outputTokens) && positiveDuration(settled.responseMilliseconds)
|
|
475
475
|
? { outputTokens: settled.outputTokens, responseMilliseconds: settled.responseMilliseconds }
|