@astralyn/pi 0.82.5 → 0.82.7
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 +55 -0
- package/README.md +1 -1
- package/dist/cli/project-trust.d.ts.map +1 -1
- package/dist/cli/project-trust.js +4 -1
- package/dist/cli/project-trust.js.map +1 -1
- package/dist/cli/session-picker.d.ts.map +1 -1
- package/dist/cli/session-picker.js +11 -13
- package/dist/cli/session-picker.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +4 -22
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/types.d.ts +32 -5
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +2 -2
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tools/bash.d.ts +1 -5
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +16 -41
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +42 -11
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +6 -7
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +17 -8
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js +3 -3
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +3 -13
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +6 -36
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +4 -4
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/render-utils.d.ts +7 -0
- package/dist/core/tools/render-utils.d.ts.map +1 -1
- package/dist/core/tools/render-utils.js +11 -0
- package/dist/core/tools/render-utils.js.map +1 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js +2 -3
- package/dist/core/tools/write.js.map +1 -1
- package/dist/extensions/deepwiki/render.d.ts.map +1 -1
- package/dist/extensions/deepwiki/render.js +34 -22
- package/dist/extensions/deepwiki/render.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +43 -6
- package/dist/extensions/plan/constants.d.ts.map +1 -1
- package/dist/extensions/plan/constants.js +51 -8
- package/dist/extensions/plan/constants.js.map +1 -1
- package/dist/extensions/plan/index.d.ts +12 -3
- package/dist/extensions/plan/index.d.ts.map +1 -1
- package/dist/extensions/plan/index.js +173 -35
- package/dist/extensions/plan/index.js.map +1 -1
- package/dist/extensions/plan/schema.d.ts +5 -0
- package/dist/extensions/plan/schema.d.ts.map +1 -1
- package/dist/extensions/plan/schema.js.map +1 -1
- package/dist/extensions/plan/state.d.ts +11 -2
- package/dist/extensions/plan/state.d.ts.map +1 -1
- package/dist/extensions/plan/state.js +31 -9
- package/dist/extensions/plan/state.js.map +1 -1
- package/dist/extensions/plan/storage.d.ts +11 -3
- package/dist/extensions/plan/storage.d.ts.map +1 -1
- package/dist/extensions/plan/storage.js +45 -20
- package/dist/extensions/plan/storage.js.map +1 -1
- package/dist/extensions/plan/view.d.ts +44 -0
- package/dist/extensions/plan/view.d.ts.map +1 -0
- package/dist/extensions/plan/view.js +149 -0
- package/dist/extensions/plan/view.js.map +1 -0
- package/dist/extensions/question/dialog.d.ts.map +1 -1
- package/dist/extensions/question/dialog.js +29 -10
- package/dist/extensions/question/dialog.js.map +1 -1
- package/dist/extensions/question/index.d.ts.map +1 -1
- package/dist/extensions/question/index.js +7 -2
- package/dist/extensions/question/index.js.map +1 -1
- package/dist/extensions/question/render.d.ts +4 -8
- package/dist/extensions/question/render.d.ts.map +1 -1
- package/dist/extensions/question/render.js +202 -29
- package/dist/extensions/question/render.js.map +1 -1
- package/dist/extensions/question/results.d.ts.map +1 -1
- package/dist/extensions/question/results.js +1 -0
- package/dist/extensions/question/results.js.map +1 -1
- package/dist/extensions/question/schema.d.ts.map +1 -1
- package/dist/extensions/question/schema.js +2 -2
- package/dist/extensions/question/schema.js.map +1 -1
- package/dist/extensions/question/types.d.ts +1 -0
- package/dist/extensions/question/types.d.ts.map +1 -1
- package/dist/extensions/question/types.js +1 -1
- package/dist/extensions/question/types.js.map +1 -1
- package/dist/extensions/router/stream.d.ts +1 -0
- package/dist/extensions/router/stream.d.ts.map +1 -1
- package/dist/extensions/router/stream.js +75 -0
- package/dist/extensions/router/stream.js.map +1 -1
- package/dist/extensions/subagent/activity.d.ts.map +1 -1
- package/dist/extensions/subagent/activity.js +25 -4
- package/dist/extensions/subagent/activity.js.map +1 -1
- package/dist/extensions/subagent/constants.d.ts +1 -0
- package/dist/extensions/subagent/constants.d.ts.map +1 -1
- package/dist/extensions/subagent/constants.js +1 -0
- package/dist/extensions/subagent/constants.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +16 -0
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/render.d.ts.map +1 -1
- package/dist/extensions/subagent/render.js +352 -152
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +13 -9
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/retry.d.ts +17 -0
- package/dist/extensions/subagent/retry.d.ts.map +1 -0
- package/dist/extensions/subagent/retry.js +41 -0
- package/dist/extensions/subagent/retry.js.map +1 -0
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +37 -7
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +2 -2
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/subagent/sdk-runner.d.ts +6 -0
- package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
- package/dist/extensions/subagent/sdk-runner.js +46 -24
- package/dist/extensions/subagent/sdk-runner.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +7 -0
- package/dist/extensions/subagent/types.d.ts.map +1 -1
- package/dist/extensions/subagent/types.js.map +1 -1
- package/dist/extensions/todo/constants.d.ts +17 -6
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +22 -9
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts +0 -14
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +13 -1
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +53 -2
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +57 -8
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +16 -5
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +874 -130
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts +16 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +428 -1
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +2 -1
- package/dist/migrations.js.map +1 -1
- package/dist/modes/interactive/components/armin.d.ts.map +1 -1
- package/dist/modes/interactive/components/armin.js +5 -0
- package/dist/modes/interactive/components/armin.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +1 -0
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +4 -2
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/countdown-timer.d.ts +13 -6
- package/dist/modes/interactive/components/countdown-timer.d.ts.map +1 -1
- package/dist/modes/interactive/components/countdown-timer.js +54 -17
- package/dist/modes/interactive/components/countdown-timer.js.map +1 -1
- package/dist/modes/interactive/components/extension-selector.d.ts +6 -1
- package/dist/modes/interactive/components/extension-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-selector.js +23 -11
- package/dist/modes/interactive/components/extension-selector.js.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts +11 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.js +56 -0
- package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +3 -0
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +52 -2
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +10 -0
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +139 -18
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/components/tool-group.d.ts +1 -0
- package/dist/modes/interactive/components/tool-group.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-group.js +5 -0
- package/dist/modes/interactive/components/tool-group.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts +10 -1
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/tree-selector.js +55 -2
- package/dist/modes/interactive/components/tree-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +5 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +79 -19
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +3 -2
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +8 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/utils/paths.d.ts +6 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +8 -0
- package/dist/utils/paths.js.map +1 -1
- package/docs/bundled/README.md +1 -0
- package/docs/bundled/extensions/deepwiki.md +1 -1
- package/docs/bundled/extensions/plan.md +13 -10
- package/docs/bundled/extensions/question.md +7 -5
- package/docs/bundled/extensions/router.md +3 -4
- package/docs/bundled/extensions/statusline.md +0 -1
- package/docs/bundled/extensions/subagent.md +5 -4
- package/docs/bundled/extensions/todo.md +9 -7
- package/docs/bundled/tool-presentation.md +17 -16
- package/docs/extensions.md +34 -5
- package/docs/rpc.md +1 -1
- package/docs/usage.md +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +1 -1
- package/dist/utils/output-decoder.d.ts +0 -52
- package/dist/utils/output-decoder.d.ts.map +0 -1
- package/dist/utils/output-decoder.js +0 -134
- package/dist/utils/output-decoder.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.js","sourceRoot":"","sources":["../../../src/extensions/plan/state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"state.js","sourceRoot":"","sources":["../../../src/extensions/plan/state.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * plan/state.ts — per-session plan-mode state, persisted as custom entries.\n *\n * State is conversation-backed like the todo extension: every transition is\n * appended as a `plan-mode` custom entry (never sent to the LLM), and lifecycle\n * handlers replay the current branch tail → head to restore. Custom entries are\n * copied on fork/clone, so `planFiles` keeps pointing at the right plan files\n * even though the forked session gets a new session id.\n *\n * State is keyed per session id: one Pi process can host several sessions over\n * its lifetime (resume, /tree switches), so callers re-point the active bucket\n * wherever ctx is in hand.\n */\nimport { PLAN_ENTRY_TYPE } from \"./constants.ts\";\n\nexport interface PlanSessionState {\n\t/** True from entry until the exit is fully applied (including a pending compaction). */\n\tplanning: boolean;\n\t/**\n\t * Full active-tool set snapshotted on entry; restored verbatim on exit.\n\t * Empty for legacy entries (pre-snapshot `removedTools` shape) — replay\n\t * re-derives a fresh snapshot for active planning, and an inactive legacy\n\t * entry needs no restore beyond dropping exit_plan.\n\t */\n\ttoolSnapshot: string[];\n\t/** A compact-and-execute exit was approved and is waiting for agent_settled + compaction. */\n\tawaitingCompact: boolean;\n\t/** Absolute paths of plan files saved on this conversation branch. */\n\tplanFiles: string[];\n}\n\nexport const EMPTY_PLAN_STATE: PlanSessionState = {\n\tplanning: false,\n\ttoolSnapshot: [],\n\tawaitingCompact: false,\n\tplanFiles: [],\n};\n\nconst states = new Map<string, PlanSessionState>();\nlet activeSid = \"\";\n\n/** Point the module at a session's state bucket. Call wherever ctx is in hand. */\nexport function setActivePlanSession(sid: string): void {\n\tactiveSid = sid;\n}\n\nexport function getPlanState(): PlanSessionState {\n\treturn states.get(activeSid) ?? clonePlanState(EMPTY_PLAN_STATE);\n}\n\nexport function replacePlanState(next: PlanSessionState): void {\n\tstates.set(activeSid, clonePlanState(next));\n}\n\n/** Drop in-memory state for a session (call from session_shutdown). */\nexport function disposePlanSession(sid: string): void {\n\tif (!sid) return;\n\tstates.delete(sid);\n\tif (activeSid === sid) activeSid = \"\";\n}\n\nexport function clonePlanState(source: PlanSessionState): PlanSessionState {\n\treturn {\n\t\tplanning: source.planning,\n\t\ttoolSnapshot: [...source.toolSnapshot],\n\t\tawaitingCompact: source.awaitingCompact,\n\t\tplanFiles: [...source.planFiles],\n\t};\n}\n\n/**\n * Accepts both the current shape (`toolSnapshot`) and the legacy one\n * (`removedTools`), so branches recorded before the snapshot change replay.\n */\nexport function isPlanState(value: unknown): value is PlanSessionState {\n\tconst parsed = parsePlanState(value);\n\treturn parsed !== undefined;\n}\n\nfunction parsePlanState(value: unknown): PlanSessionState | undefined {\n\tif (!value || typeof value !== \"object\") return undefined;\n\tconst record = value as Record<string, unknown>;\n\tif (typeof record.planning !== \"boolean\" || typeof record.awaitingCompact !== \"boolean\") return undefined;\n\tif (!Array.isArray(record.planFiles)) return undefined;\n\tconst snapshot = Array.isArray(record.toolSnapshot)\n\t\t? record.toolSnapshot\n\t\t: Array.isArray(record.removedTools)\n\t\t\t? [] // Legacy entries recorded removals, not a snapshot; nothing to restore from.\n\t\t\t: undefined;\n\tif (!snapshot) return undefined;\n\treturn {\n\t\tplanning: record.planning,\n\t\ttoolSnapshot: snapshot.filter((tool): tool is string => typeof tool === \"string\"),\n\t\tawaitingCompact: record.awaitingCompact,\n\t\tplanFiles: record.planFiles.filter((path): path is string => typeof path === \"string\"),\n\t};\n}\n\n/**\n * Latest plan-mode entry on the current branch, or undefined when the branch\n * has none (lets callers distinguish \"never used\" from \"explicitly exited\").\n */\nexport function replayPlanFromBranch(ctx: {\n\tsessionManager: { getBranch(): Iterable<unknown> };\n}): PlanSessionState | undefined {\n\tconst branch = Array.from(ctx.sessionManager.getBranch());\n\tfor (let i = branch.length - 1; i >= 0; i--) {\n\t\tconst item = branch[i] as { type?: string; customType?: string; data?: unknown };\n\t\tif (item.type !== \"custom\" || item.customType !== PLAN_ENTRY_TYPE) continue;\n\t\tconst parsed = parsePlanState(item.data);\n\t\tif (!parsed) continue;\n\t\treturn parsed;\n\t}\n\treturn undefined;\n}\n"]}
|
|
@@ -5,14 +5,22 @@ export interface SavePlanOptions {
|
|
|
5
5
|
cwd: string;
|
|
6
6
|
revises?: string;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/** Per-project plans directory for a cwd (same encoding as sessions). */
|
|
9
|
+
export declare function getPlansDir(cwd: string): string;
|
|
9
10
|
/** File-name slug from a plan title: lowercase, ascii-ish, bounded. */
|
|
10
11
|
export declare function slugifyTitle(title: string): string;
|
|
11
|
-
/**
|
|
12
|
-
export declare function
|
|
12
|
+
/** Local-time `YYYYMMDD-HHmm` prefix: sortable and collision-poor per project. */
|
|
13
|
+
export declare function formatPlanFileStamp(date: Date): string;
|
|
13
14
|
export declare function buildPlanDocument(options: SavePlanOptions & {
|
|
14
15
|
createdIso: string;
|
|
15
16
|
}): string;
|
|
17
|
+
/** First non-existing `<base>.md`, `<base>-2.md`, ... path in dir. */
|
|
18
|
+
export declare function resolvePlanFileName(dir: string, base: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Absolute paths of plans saved for this project, newest first (the
|
|
21
|
+
* `YYYYMMDD-HHmm` prefix makes file names sort chronologically).
|
|
22
|
+
*/
|
|
23
|
+
export declare function listProjectPlanFiles(cwd: string): string[];
|
|
16
24
|
/** Write the plan to disk and return its absolute path. */
|
|
17
25
|
export declare function savePlanFile(options: SavePlanOptions): string;
|
|
18
26
|
//# sourceMappingURL=storage.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../../src/extensions/plan/storage.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../../src/extensions/plan/storage.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQlD;AAED,kFAAkF;AAClF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAGtD;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,eAAe,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAW3F;AAED,sEAAsE;AACtE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrE;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAY1D;AAED,2DAA2D;AAC3D,wBAAgB,YAAY,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAQ7D","sourcesContent":["/**\n * plan/storage.ts — plan file layout and writing.\n *\n * Plans live under `<agentDir>/plans/<safeCwdDir>/<YYYYMMDD-HHmm>-<slug>.md`,\n * grouped by project the same way sessions are (cwdToSafeDirName). The\n * directory is only the initial storage location — the authoritative index is\n * the `planFiles` list in the session's plan-mode entries, which follows\n * fork/clone. Plans saved before the per-project layout stay in their legacy\n * `plans/<sessionId>/` directories and remain readable via those entries.\n */\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getAgentDir } from \"../../config.ts\";\nimport { cwdToSafeDirName, resolvePath } from \"../../utils/paths.ts\";\n\nexport interface SavePlanOptions {\n\tsessionId: string;\n\ttitle: string;\n\tplan: string;\n\tcwd: string;\n\trevises?: string;\n}\n\n/** Per-project plans directory for a cwd (same encoding as sessions). */\nexport function getPlansDir(cwd: string): string {\n\treturn join(getAgentDir(), \"plans\", cwdToSafeDirName(resolvePath(cwd)));\n}\n\n/** File-name slug from a plan title: lowercase, ascii-ish, bounded. */\nexport function slugifyTitle(title: string): string {\n\tconst slug = title\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\")\n\t\t.slice(0, 40)\n\t\t.replace(/-+$/, \"\");\n\treturn slug || \"plan\";\n}\n\n/** Local-time `YYYYMMDD-HHmm` prefix: sortable and collision-poor per project. */\nexport function formatPlanFileStamp(date: Date): string {\n\tconst pad = (value: number) => String(value).padStart(2, \"0\");\n\treturn `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;\n}\n\nexport function buildPlanDocument(options: SavePlanOptions & { createdIso: string }): string {\n\tconst lines = [\n\t\t\"---\",\n\t\t`title: ${JSON.stringify(options.title)}`,\n\t\t`created: ${options.createdIso}`,\n\t\t`cwd: ${JSON.stringify(options.cwd)}`,\n\t\t`session: ${options.sessionId}`,\n\t];\n\tif (options.revises) lines.push(`revises: ${JSON.stringify(options.revises)}`);\n\tlines.push(\"---\", \"\", options.plan.trimEnd(), \"\");\n\treturn lines.join(\"\\n\");\n}\n\n/** First non-existing `<base>.md`, `<base>-2.md`, ... path in dir. */\nexport function resolvePlanFileName(dir: string, base: string): string {\n\tlet candidate = `${base}.md`;\n\tfor (let attempt = 2; existsSync(join(dir, candidate)); attempt++) {\n\t\tcandidate = `${base}-${attempt}.md`;\n\t}\n\treturn candidate;\n}\n\n/**\n * Absolute paths of plans saved for this project, newest first (the\n * `YYYYMMDD-HHmm` prefix makes file names sort chronologically).\n */\nexport function listProjectPlanFiles(cwd: string): string[] {\n\tconst dir = getPlansDir(cwd);\n\tlet names: string[];\n\ttry {\n\t\tnames = readdirSync(dir);\n\t} catch {\n\t\treturn [];\n\t}\n\treturn names\n\t\t.filter((name) => name.endsWith(\".md\"))\n\t\t.sort((left, right) => right.localeCompare(left))\n\t\t.map((name) => join(dir, name));\n}\n\n/** Write the plan to disk and return its absolute path. */\nexport function savePlanFile(options: SavePlanOptions): string {\n\tconst dir = getPlansDir(options.cwd);\n\tmkdirSync(dir, { recursive: true });\n\tconst now = new Date();\n\tconst fileName = resolvePlanFileName(dir, `${formatPlanFileStamp(now)}-${slugifyTitle(options.title)}`);\n\tconst path = join(dir, fileName);\n\twriteFileSync(path, buildPlanDocument({ ...options, createdIso: now.toISOString() }), \"utf-8\");\n\treturn path;\n}\n"]}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* plan/storage.ts — plan file layout and writing.
|
|
3
3
|
*
|
|
4
|
-
* Plans live under `<agentDir>/plans/<
|
|
5
|
-
*
|
|
6
|
-
* storage location — the authoritative index is
|
|
7
|
-
* session's plan-mode entries, which follows
|
|
4
|
+
* Plans live under `<agentDir>/plans/<safeCwdDir>/<YYYYMMDD-HHmm>-<slug>.md`,
|
|
5
|
+
* grouped by project the same way sessions are (cwdToSafeDirName). The
|
|
6
|
+
* directory is only the initial storage location — the authoritative index is
|
|
7
|
+
* the `planFiles` list in the session's plan-mode entries, which follows
|
|
8
|
+
* fork/clone. Plans saved before the per-project layout stay in their legacy
|
|
9
|
+
* `plans/<sessionId>/` directories and remain readable via those entries.
|
|
8
10
|
*/
|
|
9
|
-
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
10
12
|
import { join } from "node:path";
|
|
11
13
|
import { getAgentDir } from "../../config.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
import { cwdToSafeDirName, resolvePath } from "../../utils/paths.js";
|
|
15
|
+
/** Per-project plans directory for a cwd (same encoding as sessions). */
|
|
16
|
+
export function getPlansDir(cwd) {
|
|
17
|
+
return join(getAgentDir(), "plans", cwdToSafeDirName(resolvePath(cwd)));
|
|
14
18
|
}
|
|
15
19
|
/** File-name slug from a plan title: lowercase, ascii-ish, bounded. */
|
|
16
20
|
export function slugifyTitle(title) {
|
|
@@ -22,15 +26,10 @@ export function slugifyTitle(title) {
|
|
|
22
26
|
.replace(/-+$/, "");
|
|
23
27
|
return slug || "plan";
|
|
24
28
|
}
|
|
25
|
-
/**
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const match = /^(\d+)-/.exec(name);
|
|
30
|
-
if (match)
|
|
31
|
-
max = Math.max(max, Number(match[1]));
|
|
32
|
-
}
|
|
33
|
-
return max + 1;
|
|
29
|
+
/** Local-time `YYYYMMDD-HHmm` prefix: sortable and collision-poor per project. */
|
|
30
|
+
export function formatPlanFileStamp(date) {
|
|
31
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
32
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
|
34
33
|
}
|
|
35
34
|
export function buildPlanDocument(options) {
|
|
36
35
|
const lines = [
|
|
@@ -45,14 +44,40 @@ export function buildPlanDocument(options) {
|
|
|
45
44
|
lines.push("---", "", options.plan.trimEnd(), "");
|
|
46
45
|
return lines.join("\n");
|
|
47
46
|
}
|
|
47
|
+
/** First non-existing `<base>.md`, `<base>-2.md`, ... path in dir. */
|
|
48
|
+
export function resolvePlanFileName(dir, base) {
|
|
49
|
+
let candidate = `${base}.md`;
|
|
50
|
+
for (let attempt = 2; existsSync(join(dir, candidate)); attempt++) {
|
|
51
|
+
candidate = `${base}-${attempt}.md`;
|
|
52
|
+
}
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Absolute paths of plans saved for this project, newest first (the
|
|
57
|
+
* `YYYYMMDD-HHmm` prefix makes file names sort chronologically).
|
|
58
|
+
*/
|
|
59
|
+
export function listProjectPlanFiles(cwd) {
|
|
60
|
+
const dir = getPlansDir(cwd);
|
|
61
|
+
let names;
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(dir);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return names
|
|
69
|
+
.filter((name) => name.endsWith(".md"))
|
|
70
|
+
.sort((left, right) => right.localeCompare(left))
|
|
71
|
+
.map((name) => join(dir, name));
|
|
72
|
+
}
|
|
48
73
|
/** Write the plan to disk and return its absolute path. */
|
|
49
74
|
export function savePlanFile(options) {
|
|
50
|
-
const dir = getPlansDir(options.
|
|
75
|
+
const dir = getPlansDir(options.cwd);
|
|
51
76
|
mkdirSync(dir, { recursive: true });
|
|
52
|
-
const
|
|
53
|
-
const fileName = `${
|
|
77
|
+
const now = new Date();
|
|
78
|
+
const fileName = resolvePlanFileName(dir, `${formatPlanFileStamp(now)}-${slugifyTitle(options.title)}`);
|
|
54
79
|
const path = join(dir, fileName);
|
|
55
|
-
writeFileSync(path, buildPlanDocument({ ...options, createdIso:
|
|
80
|
+
writeFileSync(path, buildPlanDocument({ ...options, createdIso: now.toISOString() }), "utf-8");
|
|
56
81
|
return path;
|
|
57
82
|
}
|
|
58
83
|
//# sourceMappingURL=storage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.js","sourceRoot":"","sources":["../../../src/extensions/plan/storage.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"storage.js","sourceRoot":"","sources":["../../../src/extensions/plan/storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * plan/storage.ts — plan file layout and writing.\n *\n * Plans live under `<agentDir>/plans/<safeCwdDir>/<YYYYMMDD-HHmm>-<slug>.md`,\n * grouped by project the same way sessions are (cwdToSafeDirName). The\n * directory is only the initial storage location — the authoritative index is\n * the `planFiles` list in the session's plan-mode entries, which follows\n * fork/clone. Plans saved before the per-project layout stay in their legacy\n * `plans/<sessionId>/` directories and remain readable via those entries.\n */\nimport { existsSync, mkdirSync, readdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getAgentDir } from \"../../config.ts\";\nimport { cwdToSafeDirName, resolvePath } from \"../../utils/paths.ts\";\n\nexport interface SavePlanOptions {\n\tsessionId: string;\n\ttitle: string;\n\tplan: string;\n\tcwd: string;\n\trevises?: string;\n}\n\n/** Per-project plans directory for a cwd (same encoding as sessions). */\nexport function getPlansDir(cwd: string): string {\n\treturn join(getAgentDir(), \"plans\", cwdToSafeDirName(resolvePath(cwd)));\n}\n\n/** File-name slug from a plan title: lowercase, ascii-ish, bounded. */\nexport function slugifyTitle(title: string): string {\n\tconst slug = title\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\")\n\t\t.slice(0, 40)\n\t\t.replace(/-+$/, \"\");\n\treturn slug || \"plan\";\n}\n\n/** Local-time `YYYYMMDD-HHmm` prefix: sortable and collision-poor per project. */\nexport function formatPlanFileStamp(date: Date): string {\n\tconst pad = (value: number) => String(value).padStart(2, \"0\");\n\treturn `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;\n}\n\nexport function buildPlanDocument(options: SavePlanOptions & { createdIso: string }): string {\n\tconst lines = [\n\t\t\"---\",\n\t\t`title: ${JSON.stringify(options.title)}`,\n\t\t`created: ${options.createdIso}`,\n\t\t`cwd: ${JSON.stringify(options.cwd)}`,\n\t\t`session: ${options.sessionId}`,\n\t];\n\tif (options.revises) lines.push(`revises: ${JSON.stringify(options.revises)}`);\n\tlines.push(\"---\", \"\", options.plan.trimEnd(), \"\");\n\treturn lines.join(\"\\n\");\n}\n\n/** First non-existing `<base>.md`, `<base>-2.md`, ... path in dir. */\nexport function resolvePlanFileName(dir: string, base: string): string {\n\tlet candidate = `${base}.md`;\n\tfor (let attempt = 2; existsSync(join(dir, candidate)); attempt++) {\n\t\tcandidate = `${base}-${attempt}.md`;\n\t}\n\treturn candidate;\n}\n\n/**\n * Absolute paths of plans saved for this project, newest first (the\n * `YYYYMMDD-HHmm` prefix makes file names sort chronologically).\n */\nexport function listProjectPlanFiles(cwd: string): string[] {\n\tconst dir = getPlansDir(cwd);\n\tlet names: string[];\n\ttry {\n\t\tnames = readdirSync(dir);\n\t} catch {\n\t\treturn [];\n\t}\n\treturn names\n\t\t.filter((name) => name.endsWith(\".md\"))\n\t\t.sort((left, right) => right.localeCompare(left))\n\t\t.map((name) => join(dir, name));\n}\n\n/** Write the plan to disk and return its absolute path. */\nexport function savePlanFile(options: SavePlanOptions): string {\n\tconst dir = getPlansDir(options.cwd);\n\tmkdirSync(dir, { recursive: true });\n\tconst now = new Date();\n\tconst fileName = resolvePlanFileName(dir, `${formatPlanFileStamp(now)}-${slugifyTitle(options.title)}`);\n\tconst path = join(dir, fileName);\n\twriteFileSync(path, buildPlanDocument({ ...options, createdIso: now.toISOString() }), \"utf-8\");\n\treturn path;\n}\n"]}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan/view.ts — approval-dialog context and exit_plan tool rendering.
|
|
3
|
+
*
|
|
4
|
+
* `exit_plan` carries the whole plan as a parameter, and the default renderer
|
|
5
|
+
* echoes parameters verbatim: the row would repeat, escaped onto one line, the
|
|
6
|
+
* markdown the model just printed as response text. Collapsed rows therefore
|
|
7
|
+
* carry the title only, and the plan body is available on expand.
|
|
8
|
+
*
|
|
9
|
+
* The result text is written for the model (it restates tool-state precedence
|
|
10
|
+
* rules), so the result row renders from `details` instead.
|
|
11
|
+
*/
|
|
12
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { AgentToolResult, ExtensionContext } from "../../core/extensions/types.ts";
|
|
14
|
+
import type { CustomMessage } from "../../core/messages.ts";
|
|
15
|
+
import { type Theme } from "../../modes/interactive/theme/theme.ts";
|
|
16
|
+
import type { ExitPlanDetails, PlanKickoffDetails } from "./schema.ts";
|
|
17
|
+
/** Collapse the home prefix so a plan path stays on one line. */
|
|
18
|
+
export declare function shortenPlanPath(path: string): string;
|
|
19
|
+
export declare function formatApprovalSubtitle(ctx: ExtensionContext): string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The kickoff message body repeats the whole plan for the LLM, so the default
|
|
22
|
+
* renderer would flood the chat with markdown the user already approved.
|
|
23
|
+
* Collapsed: a two-line card (title + saved path with the expand hint).
|
|
24
|
+
* Expanded: the complete kickoff markdown, styled like the exit_plan body.
|
|
25
|
+
*/
|
|
26
|
+
export declare function renderPlanKickoffMessage(message: CustomMessage<PlanKickoffDetails>, options: {
|
|
27
|
+
expanded: boolean;
|
|
28
|
+
outputPad: number;
|
|
29
|
+
}, theme: Theme): Component;
|
|
30
|
+
type ExitPlanCallArgs = {
|
|
31
|
+
title?: unknown;
|
|
32
|
+
plan?: unknown;
|
|
33
|
+
revises?: unknown;
|
|
34
|
+
};
|
|
35
|
+
/** Title-only headline when collapsed; Markdown plan body and revision target on expand. */
|
|
36
|
+
export declare function renderExitPlanCall(args: ExitPlanCallArgs | undefined, theme: Theme, expanded: boolean): Component;
|
|
37
|
+
/**
|
|
38
|
+
* What the user needs to know: where the plan landed and what happens next.
|
|
39
|
+
* Falls back to the model-facing text when details are missing — a thrown
|
|
40
|
+
* approval (interrupt) has no details to render from.
|
|
41
|
+
*/
|
|
42
|
+
export declare function formatExitPlanResult(result: AgentToolResult<ExitPlanDetails>, theme: Theme): string;
|
|
43
|
+
export {};
|
|
44
|
+
//# sourceMappingURL=view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../../../src/extensions/plan/view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,KAAK,SAAS,EAAqC,MAAM,wBAAwB,CAAC;AAC3F,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACxF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAE5D,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AACtF,OAAO,KAAK,EAAoB,eAAe,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEzF,iEAAiE;AACjE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOpD;AAcD,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,GAAG,SAAS,CAKhF;AAID;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACvC,OAAO,EAAE,aAAa,CAAC,kBAAkB,CAAC,EAC1C,OAAO,EAAE;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EACjD,KAAK,EAAE,KAAK,GACV,SAAS,CAsCX;AAKD,KAAK,gBAAgB,GAAG;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAM/E,4FAA4F;AAC5F,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,GAAG,SAAS,CAsBjH;AAsBD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,eAAe,CAAC,eAAe,CAAC,EAAE,KAAK,EAAE,KAAK,GAAG,MAAM,CAgBnG","sourcesContent":["/**\n * plan/view.ts — approval-dialog context and exit_plan tool rendering.\n *\n * `exit_plan` carries the whole plan as a parameter, and the default renderer\n * echoes parameters verbatim: the row would repeat, escaped onto one line, the\n * markdown the model just printed as response text. Collapsed rows therefore\n * carry the title only, and the plan body is available on expand.\n *\n * The result text is written for the model (it restates tool-state precedence\n * rules), so the result row renders from `details` instead.\n */\nimport { type Component, Container, Markdown, Spacer, Text } from \"@earendil-works/pi-tui\";\nimport type { AgentToolResult, ExtensionContext } from \"../../core/extensions/types.ts\";\nimport type { CustomMessage } from \"../../core/messages.ts\";\nimport { keyText } from \"../../modes/interactive/components/keybinding-hints.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport type { ExitPlanDecision, ExitPlanDetails, PlanKickoffDetails } from \"./schema.ts\";\n\n/** Collapse the home prefix so a plan path stays on one line. */\nexport function shortenPlanPath(path: string): string {\n\tconst rawHome = process.env.USERPROFILE || process.env.HOME || \"\";\n\tconst home = rawHome.replace(/[\\\\/]+$/, \"\");\n\tif (!home) return path;\n\tconst normalize = (value: string) => value.replace(/\\\\/g, \"/\").toLowerCase();\n\tif (!normalize(path).startsWith(`${normalize(home)}/`)) return path;\n\treturn `~${path.slice(home.length).replace(/\\\\/g, \"/\")}`;\n}\n\n/**\n * Context pressure is the fact the compact-or-not decision turns on, so the\n * dialog states it instead of making the user leave and check the footer.\n */\nfunction formatTokenCount(tokenCount: number): string {\n\tif (tokenCount < 1000) return `${tokenCount}`;\n\tif (tokenCount < 10_000) return `${(tokenCount / 1000).toFixed(1)}k`;\n\tif (tokenCount < 1_000_000) return `${Math.round(tokenCount / 1000)}k`;\n\tif (tokenCount < 10_000_000) return `${(tokenCount / 1_000_000).toFixed(1)}M`;\n\treturn `${Math.round(tokenCount / 1_000_000)}M`;\n}\n\nexport function formatApprovalSubtitle(ctx: ExtensionContext): string | undefined {\n\tconst usage = ctx.getContextUsage();\n\tif (usage?.percent == null) return undefined;\n\tconst window = usage.contextWindow > 0 ? ` of ${formatTokenCount(usage.contextWindow)}` : \"\";\n\treturn `Context ${usage.percent.toFixed(1)}%${window}`;\n}\n\n// ---- kickoff message rendering ----------------------------------------------\n\n/**\n * The kickoff message body repeats the whole plan for the LLM, so the default\n * renderer would flood the chat with markdown the user already approved.\n * Collapsed: a two-line card (title + saved path with the expand hint).\n * Expanded: the complete kickoff markdown, styled like the exit_plan body.\n */\nexport function renderPlanKickoffMessage(\n\tmessage: CustomMessage<PlanKickoffDetails>,\n\toptions: { expanded: boolean; outputPad: number },\n\ttheme: Theme,\n): Component {\n\tconst details = (message.details ?? {}) as Partial<PlanKickoffDetails>;\n\tconst title = typeof details.title === \"string\" && details.title.trim() ? details.title.trim() : \"plan\";\n\tconst planPath = typeof details.planPath === \"string\" ? details.planPath.trim() : \"\";\n\n\tconst container = new Container();\n\tcontainer.addChild(\n\t\tnew Text(\n\t\t\t[theme.fg(\"toolTitle\", theme.bold(\"plan\")), theme.fg(\"text\", `Executing \"${title}\"`)].join(\" \"),\n\t\t\toptions.outputPad,\n\t\t\t0,\n\t\t),\n\t);\n\tconst pathText = planPath ? `${shortenPlanPath(planPath)} ` : \"\";\n\tconst secondLine = options.expanded\n\t\t? planPath\n\t\t\t? theme.fg(\"dim\", shortenPlanPath(planPath))\n\t\t\t: undefined\n\t\t: theme.fg(\"dim\", `${pathText}(${keyText(\"app.tools.expand\")} to expand)`);\n\tif (secondLine) container.addChild(new Text(secondLine, options.outputPad, 0));\n\tif (!options.expanded) return container;\n\n\tconst body =\n\t\ttypeof message.content === \"string\"\n\t\t\t? message.content\n\t\t\t: message.content\n\t\t\t\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t\t\t\t.map((block) => block.text)\n\t\t\t\t\t.join(\"\\n\");\n\tif (body.trim()) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(\n\t\t\tnew Markdown(body, options.outputPad, 0, getMarkdownTheme(), {\n\t\t\t\tcolor: (text) => theme.fg(\"toolOutput\", text),\n\t\t\t}),\n\t\t);\n\t}\n\treturn container;\n}\n\n// ---- tool call rendering ----------------------------------------------------\n// Args arrive off the wire and stream in, so every field is read defensively.\n\ntype ExitPlanCallArgs = { title?: unknown; plan?: unknown; revises?: unknown };\n\nfunction callText(value: unknown): string {\n\treturn typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Title-only headline when collapsed; Markdown plan body and revision target on expand. */\nexport function renderExitPlanCall(args: ExitPlanCallArgs | undefined, theme: Theme, expanded: boolean): Component {\n\tconst title = callText(args?.title);\n\tconst headline = [\n\t\ttheme.fg(\"toolTitle\", theme.bold(\"exit_plan\")),\n\t\ttitle ? theme.fg(\"text\", title) : theme.fg(\"dim\", \"…\"),\n\t].join(\" \");\n\tif (!expanded) return new Text(headline, 0, 0);\n\n\tconst container = new Container();\n\tcontainer.addChild(new Text(headline, 0, 0));\n\tconst revises = callText(args?.revises);\n\tif (revises) container.addChild(new Text(theme.fg(\"dim\", `revises ${revises}`), 0, 0));\n\tconst plan = callText(args?.plan);\n\tif (plan) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(\n\t\t\tnew Markdown(plan, 0, 0, getMarkdownTheme(), {\n\t\t\t\tcolor: (text) => theme.fg(\"toolOutput\", text),\n\t\t\t}),\n\t\t);\n\t}\n\treturn container;\n}\n\n// ---- tool result rendering --------------------------------------------------\n\nconst DECISION_SUMMARY: Record<ExitPlanDecision, string> = {\n\texecute: \"Executing with full context\",\n\tcompactAndExecute: \"Compacting context, then executing\",\n\tkeepPlanning: \"Still planning\",\n\tcancelled: \"No approval was pending\",\n};\n\nfunction isExitPlanDecision(value: unknown): value is ExitPlanDecision {\n\treturn value === \"execute\" || value === \"compactAndExecute\" || value === \"keepPlanning\" || value === \"cancelled\";\n}\n\nfunction resultText(result: AgentToolResult<ExitPlanDetails>): string {\n\treturn result.content\n\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\"\\n\");\n}\n\n/**\n * What the user needs to know: where the plan landed and what happens next.\n * Falls back to the model-facing text when details are missing — a thrown\n * approval (interrupt) has no details to render from.\n */\nexport function formatExitPlanResult(result: AgentToolResult<ExitPlanDetails>, theme: Theme): string {\n\tconst details = result.details as unknown;\n\tif (!details || typeof details !== \"object\" || Array.isArray(details)) {\n\t\treturn theme.fg(\"dim\", resultText(result));\n\t}\n\tconst decision = (details as Record<string, unknown>).decision;\n\tif (!isExitPlanDecision(decision)) return theme.fg(\"dim\", resultText(result));\n\tconst rawPlanPath = (details as Record<string, unknown>).planPath;\n\n\tconst lines: string[] = [];\n\tif (typeof rawPlanPath === \"string\" && rawPlanPath) {\n\t\tlines.push(theme.fg(\"dim\", `Saved to ${shortenPlanPath(rawPlanPath)}`));\n\t}\n\tconst decisionColor = decision === \"cancelled\" ? \"warning\" : \"text\";\n\tlines.push(theme.fg(decisionColor, DECISION_SUMMARY[decision]));\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan/view.ts — approval-dialog context and exit_plan tool rendering.
|
|
3
|
+
*
|
|
4
|
+
* `exit_plan` carries the whole plan as a parameter, and the default renderer
|
|
5
|
+
* echoes parameters verbatim: the row would repeat, escaped onto one line, the
|
|
6
|
+
* markdown the model just printed as response text. Collapsed rows therefore
|
|
7
|
+
* carry the title only, and the plan body is available on expand.
|
|
8
|
+
*
|
|
9
|
+
* The result text is written for the model (it restates tool-state precedence
|
|
10
|
+
* rules), so the result row renders from `details` instead.
|
|
11
|
+
*/
|
|
12
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import { keyText } from "../../modes/interactive/components/keybinding-hints.js";
|
|
14
|
+
import { getMarkdownTheme } from "../../modes/interactive/theme/theme.js";
|
|
15
|
+
/** Collapse the home prefix so a plan path stays on one line. */
|
|
16
|
+
export function shortenPlanPath(path) {
|
|
17
|
+
const rawHome = process.env.USERPROFILE || process.env.HOME || "";
|
|
18
|
+
const home = rawHome.replace(/[\\/]+$/, "");
|
|
19
|
+
if (!home)
|
|
20
|
+
return path;
|
|
21
|
+
const normalize = (value) => value.replace(/\\/g, "/").toLowerCase();
|
|
22
|
+
if (!normalize(path).startsWith(`${normalize(home)}/`))
|
|
23
|
+
return path;
|
|
24
|
+
return `~${path.slice(home.length).replace(/\\/g, "/")}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Context pressure is the fact the compact-or-not decision turns on, so the
|
|
28
|
+
* dialog states it instead of making the user leave and check the footer.
|
|
29
|
+
*/
|
|
30
|
+
function formatTokenCount(tokenCount) {
|
|
31
|
+
if (tokenCount < 1000)
|
|
32
|
+
return `${tokenCount}`;
|
|
33
|
+
if (tokenCount < 10_000)
|
|
34
|
+
return `${(tokenCount / 1000).toFixed(1)}k`;
|
|
35
|
+
if (tokenCount < 1_000_000)
|
|
36
|
+
return `${Math.round(tokenCount / 1000)}k`;
|
|
37
|
+
if (tokenCount < 10_000_000)
|
|
38
|
+
return `${(tokenCount / 1_000_000).toFixed(1)}M`;
|
|
39
|
+
return `${Math.round(tokenCount / 1_000_000)}M`;
|
|
40
|
+
}
|
|
41
|
+
export function formatApprovalSubtitle(ctx) {
|
|
42
|
+
const usage = ctx.getContextUsage();
|
|
43
|
+
if (usage?.percent == null)
|
|
44
|
+
return undefined;
|
|
45
|
+
const window = usage.contextWindow > 0 ? ` of ${formatTokenCount(usage.contextWindow)}` : "";
|
|
46
|
+
return `Context ${usage.percent.toFixed(1)}%${window}`;
|
|
47
|
+
}
|
|
48
|
+
// ---- kickoff message rendering ----------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* The kickoff message body repeats the whole plan for the LLM, so the default
|
|
51
|
+
* renderer would flood the chat with markdown the user already approved.
|
|
52
|
+
* Collapsed: a two-line card (title + saved path with the expand hint).
|
|
53
|
+
* Expanded: the complete kickoff markdown, styled like the exit_plan body.
|
|
54
|
+
*/
|
|
55
|
+
export function renderPlanKickoffMessage(message, options, theme) {
|
|
56
|
+
const details = (message.details ?? {});
|
|
57
|
+
const title = typeof details.title === "string" && details.title.trim() ? details.title.trim() : "plan";
|
|
58
|
+
const planPath = typeof details.planPath === "string" ? details.planPath.trim() : "";
|
|
59
|
+
const container = new Container();
|
|
60
|
+
container.addChild(new Text([theme.fg("toolTitle", theme.bold("plan")), theme.fg("text", `Executing "${title}"`)].join(" "), options.outputPad, 0));
|
|
61
|
+
const pathText = planPath ? `${shortenPlanPath(planPath)} ` : "";
|
|
62
|
+
const secondLine = options.expanded
|
|
63
|
+
? planPath
|
|
64
|
+
? theme.fg("dim", shortenPlanPath(planPath))
|
|
65
|
+
: undefined
|
|
66
|
+
: theme.fg("dim", `${pathText}(${keyText("app.tools.expand")} to expand)`);
|
|
67
|
+
if (secondLine)
|
|
68
|
+
container.addChild(new Text(secondLine, options.outputPad, 0));
|
|
69
|
+
if (!options.expanded)
|
|
70
|
+
return container;
|
|
71
|
+
const body = typeof message.content === "string"
|
|
72
|
+
? message.content
|
|
73
|
+
: message.content
|
|
74
|
+
.filter((block) => block.type === "text")
|
|
75
|
+
.map((block) => block.text)
|
|
76
|
+
.join("\n");
|
|
77
|
+
if (body.trim()) {
|
|
78
|
+
container.addChild(new Spacer(1));
|
|
79
|
+
container.addChild(new Markdown(body, options.outputPad, 0, getMarkdownTheme(), {
|
|
80
|
+
color: (text) => theme.fg("toolOutput", text),
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
return container;
|
|
84
|
+
}
|
|
85
|
+
function callText(value) {
|
|
86
|
+
return typeof value === "string" ? value.trim() : "";
|
|
87
|
+
}
|
|
88
|
+
/** Title-only headline when collapsed; Markdown plan body and revision target on expand. */
|
|
89
|
+
export function renderExitPlanCall(args, theme, expanded) {
|
|
90
|
+
const title = callText(args?.title);
|
|
91
|
+
const headline = [
|
|
92
|
+
theme.fg("toolTitle", theme.bold("exit_plan")),
|
|
93
|
+
title ? theme.fg("text", title) : theme.fg("dim", "…"),
|
|
94
|
+
].join(" ");
|
|
95
|
+
if (!expanded)
|
|
96
|
+
return new Text(headline, 0, 0);
|
|
97
|
+
const container = new Container();
|
|
98
|
+
container.addChild(new Text(headline, 0, 0));
|
|
99
|
+
const revises = callText(args?.revises);
|
|
100
|
+
if (revises)
|
|
101
|
+
container.addChild(new Text(theme.fg("dim", `revises ${revises}`), 0, 0));
|
|
102
|
+
const plan = callText(args?.plan);
|
|
103
|
+
if (plan) {
|
|
104
|
+
container.addChild(new Spacer(1));
|
|
105
|
+
container.addChild(new Markdown(plan, 0, 0, getMarkdownTheme(), {
|
|
106
|
+
color: (text) => theme.fg("toolOutput", text),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
return container;
|
|
110
|
+
}
|
|
111
|
+
// ---- tool result rendering --------------------------------------------------
|
|
112
|
+
const DECISION_SUMMARY = {
|
|
113
|
+
execute: "Executing with full context",
|
|
114
|
+
compactAndExecute: "Compacting context, then executing",
|
|
115
|
+
keepPlanning: "Still planning",
|
|
116
|
+
cancelled: "No approval was pending",
|
|
117
|
+
};
|
|
118
|
+
function isExitPlanDecision(value) {
|
|
119
|
+
return value === "execute" || value === "compactAndExecute" || value === "keepPlanning" || value === "cancelled";
|
|
120
|
+
}
|
|
121
|
+
function resultText(result) {
|
|
122
|
+
return result.content
|
|
123
|
+
.filter((block) => block.type === "text")
|
|
124
|
+
.map((block) => block.text)
|
|
125
|
+
.join("\n");
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What the user needs to know: where the plan landed and what happens next.
|
|
129
|
+
* Falls back to the model-facing text when details are missing — a thrown
|
|
130
|
+
* approval (interrupt) has no details to render from.
|
|
131
|
+
*/
|
|
132
|
+
export function formatExitPlanResult(result, theme) {
|
|
133
|
+
const details = result.details;
|
|
134
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
135
|
+
return theme.fg("dim", resultText(result));
|
|
136
|
+
}
|
|
137
|
+
const decision = details.decision;
|
|
138
|
+
if (!isExitPlanDecision(decision))
|
|
139
|
+
return theme.fg("dim", resultText(result));
|
|
140
|
+
const rawPlanPath = details.planPath;
|
|
141
|
+
const lines = [];
|
|
142
|
+
if (typeof rawPlanPath === "string" && rawPlanPath) {
|
|
143
|
+
lines.push(theme.fg("dim", `Saved to ${shortenPlanPath(rawPlanPath)}`));
|
|
144
|
+
}
|
|
145
|
+
const decisionColor = decision === "cancelled" ? "warning" : "text";
|
|
146
|
+
lines.push(theme.fg(decisionColor, DECISION_SUMMARY[decision]));
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=view.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.js","sourceRoot":"","sources":["../../../src/extensions/plan/view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAkB,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAG3F,OAAO,EAAE,OAAO,EAAE,MAAM,wDAAwD,CAAC;AACjF,OAAO,EAAE,gBAAgB,EAAc,MAAM,wCAAwC,CAAC;AAGtF,iEAAiE;AACjE,MAAM,UAAU,eAAe,CAAC,IAAY,EAAU;IACrD,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;IAClE,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;IAC5C,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,MAAM,SAAS,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC;IAC7E,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACpE,OAAO,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,CAAC;AAAA,CACzD;AAED;;;GAGG;AACH,SAAS,gBAAgB,CAAC,UAAkB,EAAU;IACrD,IAAI,UAAU,GAAG,IAAI;QAAE,OAAO,GAAG,UAAU,EAAE,CAAC;IAC9C,IAAI,UAAU,GAAG,MAAM;QAAE,OAAO,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC;IACrE,IAAI,UAAU,GAAG,SAAS;QAAE,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC;IACvE,IAAI,UAAU,GAAG,UAAU;QAAE,OAAO,GAAG,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC;IAC9E,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,SAAS,CAAC,GAAG,CAAC;AAAA,CAChD;AAED,MAAM,UAAU,sBAAsB,CAAC,GAAqB,EAAsB;IACjF,MAAM,KAAK,GAAG,GAAG,CAAC,eAAe,EAAE,CAAC;IACpC,IAAI,KAAK,EAAE,OAAO,IAAI,IAAI;QAAE,OAAO,SAAS,CAAC;IAC7C,MAAM,MAAM,GAAG,KAAK,CAAC,aAAa,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,gBAAgB,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7F,OAAO,WAAW,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,MAAM,EAAE,CAAC;AAAA,CACvD;AAED,gFAAgF;AAEhF;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CACvC,OAA0C,EAC1C,OAAiD,EACjD,KAAY,EACA;IACZ,MAAM,OAAO,GAAG,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAgC,CAAC;IACvE,MAAM,KAAK,GAAG,OAAO,OAAO,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;IACxG,MAAM,QAAQ,GAAG,OAAO,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAErF,MAAM,SAAS,GAAG,IAAI,SAAS,EAAE,CAAC;IAClC,SAAS,CAAC,QAAQ,CACjB,IAAI,IAAI,CACP,CAAC,KAAK,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAC/F,OAAO,CAAC,SAAS,EACjB,CAAC,CACD,CACD,CAAC;IACF,MAAM,QAAQ,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACjE,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ;QAClC,CAAC,CAAC,QAAQ;YACT,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAC;YAC5C,CAAC,CAAC,SAAS;QACZ,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,QAAQ,IAAI,OAAO,CAAC,kBAAkB,CAAC,aAAa,CAAC,CAAC;IAC5E,IAAI,UAAU;QAAE,SAAS,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC;IAC/E,IAAI,CAAC,OAAO,CAAC,QAAQ;QAAE,OAAO,SAAS,CAAC;IAExC,MAAM,IAAI,GACT,OAAO,OAAO,CAAC,OAAO,KAAK,QAAQ;QAClC,CAAC,CAAC,OAAO,CAAC,OAAO;QACjB,CAAC,CAAC,OAAO,CAAC,OAAO;aACd,MAAM,CAAC,CAAC,KAAK,EAA2C,EAAE,CAAC,KAAK,CAAC,IAAI,KAAK,MAAM,CAAC;aACjF,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;aAC1B,IAAI,CAAC,IAAI,CAAC,CAAC;IAChB,IAAI,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC;QACjB,SAAS,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAClC,SAAS,CAAC,QAAQ,CACjB,IAAI,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,EAAE,gBAAgB,EAAE,EAAE;YAC5D,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,IAAI,CAAC;SAC7C,CAAC,CACF,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AAAA,CACjB;AAOD,SAAS,QAAQ,CAAC,KAAc,EAAU;IACzC,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AAAA,CACrD;AAED,4FAA4F;AAC5F,MAAM,UAAU,kBAAkB,CAAC,IAAkC,EAAE,KAAY,EAAE,QAAiB,EAAa;IAClH,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACpC,MAAM,QAAQ,GAAG;QAChB,KAAK,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC9C,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,KAAG,CAAC;KACtD,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACZ,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAE/C,MAAM,SAAS,GAAG,IAAI,SAAS,EAAE,CAAC;IAClC,SAAS,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACxC,IAAI,OAAO;QAAE,SAAS,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACvF,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAClC,IAAI,IAAI,EAAE,CAAC;QACV,SAAS,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAClC,SAAS,CAAC,QAAQ,CACjB,IAAI,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,EAAE,gBAAgB,EAAE,EAAE;YAC5C,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,IAAI,CAAC;SAC7C,CAAC,CACF,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AAAA,CACjB;AAED,gFAAgF;AAEhF,MAAM,gBAAgB,GAAqC;IAC1D,OAAO,EAAE,6BAA6B;IACtC,iBAAiB,EAAE,oCAAoC;IACvD,YAAY,EAAE,gBAAgB;IAC9B,SAAS,EAAE,yBAAyB;CACpC,CAAC;AAEF,SAAS,kBAAkB,CAAC,KAAc,EAA6B;IACtE,OAAO,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,mBAAmB,IAAI,KAAK,KAAK,cAAc,IAAI,KAAK,KAAK,WAAW,CAAC;AAAA,CACjH;AAED,SAAS,UAAU,CAAC,MAAwC,EAAU;IACrE,OAAO,MAAM,CAAC,OAAO;SACnB,MAAM,CAAC,CAAC,KAAK,EAA2C,EAAE,CAAC,KAAK,CAAC,IAAI,KAAK,MAAM,CAAC;SACjF,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;SAC1B,IAAI,CAAC,IAAI,CAAC,CAAC;AAAA,CACb;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAwC,EAAE,KAAY,EAAU;IACpG,MAAM,OAAO,GAAG,MAAM,CAAC,OAAkB,CAAC;IAC1C,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACvE,OAAO,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IAC5C,CAAC;IACD,MAAM,QAAQ,GAAI,OAAmC,CAAC,QAAQ,CAAC;IAC/D,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IAC9E,MAAM,WAAW,GAAI,OAAmC,CAAC,QAAQ,CAAC;IAElE,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,EAAE,CAAC;QACpD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,eAAe,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC;IACzE,CAAC;IACD,MAAM,aAAa,GAAG,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC;IACpE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,aAAa,EAAE,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IAChE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAAA,CACxB","sourcesContent":["/**\n * plan/view.ts — approval-dialog context and exit_plan tool rendering.\n *\n * `exit_plan` carries the whole plan as a parameter, and the default renderer\n * echoes parameters verbatim: the row would repeat, escaped onto one line, the\n * markdown the model just printed as response text. Collapsed rows therefore\n * carry the title only, and the plan body is available on expand.\n *\n * The result text is written for the model (it restates tool-state precedence\n * rules), so the result row renders from `details` instead.\n */\nimport { type Component, Container, Markdown, Spacer, Text } from \"@earendil-works/pi-tui\";\nimport type { AgentToolResult, ExtensionContext } from \"../../core/extensions/types.ts\";\nimport type { CustomMessage } from \"../../core/messages.ts\";\nimport { keyText } from \"../../modes/interactive/components/keybinding-hints.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport type { ExitPlanDecision, ExitPlanDetails, PlanKickoffDetails } from \"./schema.ts\";\n\n/** Collapse the home prefix so a plan path stays on one line. */\nexport function shortenPlanPath(path: string): string {\n\tconst rawHome = process.env.USERPROFILE || process.env.HOME || \"\";\n\tconst home = rawHome.replace(/[\\\\/]+$/, \"\");\n\tif (!home) return path;\n\tconst normalize = (value: string) => value.replace(/\\\\/g, \"/\").toLowerCase();\n\tif (!normalize(path).startsWith(`${normalize(home)}/`)) return path;\n\treturn `~${path.slice(home.length).replace(/\\\\/g, \"/\")}`;\n}\n\n/**\n * Context pressure is the fact the compact-or-not decision turns on, so the\n * dialog states it instead of making the user leave and check the footer.\n */\nfunction formatTokenCount(tokenCount: number): string {\n\tif (tokenCount < 1000) return `${tokenCount}`;\n\tif (tokenCount < 10_000) return `${(tokenCount / 1000).toFixed(1)}k`;\n\tif (tokenCount < 1_000_000) return `${Math.round(tokenCount / 1000)}k`;\n\tif (tokenCount < 10_000_000) return `${(tokenCount / 1_000_000).toFixed(1)}M`;\n\treturn `${Math.round(tokenCount / 1_000_000)}M`;\n}\n\nexport function formatApprovalSubtitle(ctx: ExtensionContext): string | undefined {\n\tconst usage = ctx.getContextUsage();\n\tif (usage?.percent == null) return undefined;\n\tconst window = usage.contextWindow > 0 ? ` of ${formatTokenCount(usage.contextWindow)}` : \"\";\n\treturn `Context ${usage.percent.toFixed(1)}%${window}`;\n}\n\n// ---- kickoff message rendering ----------------------------------------------\n\n/**\n * The kickoff message body repeats the whole plan for the LLM, so the default\n * renderer would flood the chat with markdown the user already approved.\n * Collapsed: a two-line card (title + saved path with the expand hint).\n * Expanded: the complete kickoff markdown, styled like the exit_plan body.\n */\nexport function renderPlanKickoffMessage(\n\tmessage: CustomMessage<PlanKickoffDetails>,\n\toptions: { expanded: boolean; outputPad: number },\n\ttheme: Theme,\n): Component {\n\tconst details = (message.details ?? {}) as Partial<PlanKickoffDetails>;\n\tconst title = typeof details.title === \"string\" && details.title.trim() ? details.title.trim() : \"plan\";\n\tconst planPath = typeof details.planPath === \"string\" ? details.planPath.trim() : \"\";\n\n\tconst container = new Container();\n\tcontainer.addChild(\n\t\tnew Text(\n\t\t\t[theme.fg(\"toolTitle\", theme.bold(\"plan\")), theme.fg(\"text\", `Executing \"${title}\"`)].join(\" \"),\n\t\t\toptions.outputPad,\n\t\t\t0,\n\t\t),\n\t);\n\tconst pathText = planPath ? `${shortenPlanPath(planPath)} ` : \"\";\n\tconst secondLine = options.expanded\n\t\t? planPath\n\t\t\t? theme.fg(\"dim\", shortenPlanPath(planPath))\n\t\t\t: undefined\n\t\t: theme.fg(\"dim\", `${pathText}(${keyText(\"app.tools.expand\")} to expand)`);\n\tif (secondLine) container.addChild(new Text(secondLine, options.outputPad, 0));\n\tif (!options.expanded) return container;\n\n\tconst body =\n\t\ttypeof message.content === \"string\"\n\t\t\t? message.content\n\t\t\t: message.content\n\t\t\t\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t\t\t\t.map((block) => block.text)\n\t\t\t\t\t.join(\"\\n\");\n\tif (body.trim()) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(\n\t\t\tnew Markdown(body, options.outputPad, 0, getMarkdownTheme(), {\n\t\t\t\tcolor: (text) => theme.fg(\"toolOutput\", text),\n\t\t\t}),\n\t\t);\n\t}\n\treturn container;\n}\n\n// ---- tool call rendering ----------------------------------------------------\n// Args arrive off the wire and stream in, so every field is read defensively.\n\ntype ExitPlanCallArgs = { title?: unknown; plan?: unknown; revises?: unknown };\n\nfunction callText(value: unknown): string {\n\treturn typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Title-only headline when collapsed; Markdown plan body and revision target on expand. */\nexport function renderExitPlanCall(args: ExitPlanCallArgs | undefined, theme: Theme, expanded: boolean): Component {\n\tconst title = callText(args?.title);\n\tconst headline = [\n\t\ttheme.fg(\"toolTitle\", theme.bold(\"exit_plan\")),\n\t\ttitle ? theme.fg(\"text\", title) : theme.fg(\"dim\", \"…\"),\n\t].join(\" \");\n\tif (!expanded) return new Text(headline, 0, 0);\n\n\tconst container = new Container();\n\tcontainer.addChild(new Text(headline, 0, 0));\n\tconst revises = callText(args?.revises);\n\tif (revises) container.addChild(new Text(theme.fg(\"dim\", `revises ${revises}`), 0, 0));\n\tconst plan = callText(args?.plan);\n\tif (plan) {\n\t\tcontainer.addChild(new Spacer(1));\n\t\tcontainer.addChild(\n\t\t\tnew Markdown(plan, 0, 0, getMarkdownTheme(), {\n\t\t\t\tcolor: (text) => theme.fg(\"toolOutput\", text),\n\t\t\t}),\n\t\t);\n\t}\n\treturn container;\n}\n\n// ---- tool result rendering --------------------------------------------------\n\nconst DECISION_SUMMARY: Record<ExitPlanDecision, string> = {\n\texecute: \"Executing with full context\",\n\tcompactAndExecute: \"Compacting context, then executing\",\n\tkeepPlanning: \"Still planning\",\n\tcancelled: \"No approval was pending\",\n};\n\nfunction isExitPlanDecision(value: unknown): value is ExitPlanDecision {\n\treturn value === \"execute\" || value === \"compactAndExecute\" || value === \"keepPlanning\" || value === \"cancelled\";\n}\n\nfunction resultText(result: AgentToolResult<ExitPlanDetails>): string {\n\treturn result.content\n\t\t.filter((block): block is { type: \"text\"; text: string } => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\"\\n\");\n}\n\n/**\n * What the user needs to know: where the plan landed and what happens next.\n * Falls back to the model-facing text when details are missing — a thrown\n * approval (interrupt) has no details to render from.\n */\nexport function formatExitPlanResult(result: AgentToolResult<ExitPlanDetails>, theme: Theme): string {\n\tconst details = result.details as unknown;\n\tif (!details || typeof details !== \"object\" || Array.isArray(details)) {\n\t\treturn theme.fg(\"dim\", resultText(result));\n\t}\n\tconst decision = (details as Record<string, unknown>).decision;\n\tif (!isExitPlanDecision(decision)) return theme.fg(\"dim\", resultText(result));\n\tconst rawPlanPath = (details as Record<string, unknown>).planPath;\n\n\tconst lines: string[] = [];\n\tif (typeof rawPlanPath === \"string\" && rawPlanPath) {\n\t\tlines.push(theme.fg(\"dim\", `Saved to ${shortenPlanPath(rawPlanPath)}`));\n\t}\n\tconst decisionColor = decision === \"cancelled\" ? \"warning\" : \"text\";\n\tlines.push(theme.fg(decisionColor, DECISION_SUMMARY[decision]));\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../../../src/extensions/question/dialog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,GAAG,EAAiC,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAc,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAChF,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AAYtF,OAAO,KAAK,EAAE,YAAY,EAA4B,QAAQ,EAAkC,MAAM,YAAY,CAAC;AAanH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,QAAQ,EAAE,EAAE,MAAM,CAAC,EAAE,WAAW;;;;;;EAkoB/E","sourcesContent":["import { Editor, type EditorTheme, Markdown, type TUI, truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport type { Keybinding, KeybindingsManager } from \"../../core/keybindings.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport { ruleBorder, wrapWithPrefix } from \"./dialog-primitives.ts\";\nimport { QUESTION_LIMITS } from \"./limits.ts\";\nimport { PreviewLinesCache, WidthCachedRender } from \"./render-cache.ts\";\nimport {\n\tdisplayOptions,\n\tfirstUnanswered,\n\thasAnswer,\n\thasMultiAnswer,\n\tnewQuestionState,\n\torderedAnswers,\n} from \"./state.ts\";\nimport type { DialogResult, DisplayOption, InputMode, Question, QuestionAnswer, QuestionOption } from \"./types.ts\";\n\ntype DialogView = \"question\" | \"review\";\n\nconst PREVIEW_MAX_LINES = 16;\n/**\n * Estimated non-preview rows in the stacked (narrow) layout: top/bottom rules,\n * tab strip + blank, question line + blank, up to 5 option rows with\n * descriptions, the \"Chat about this\" row, warning slot, and the hint line.\n * Reserved so the preview clamps short of the terminal height.\n */\nconst PREVIEW_CHROME_ROWS = 18;\n\nexport function createQuestionDialog(questions: Question[], signal?: AbortSignal) {\n\treturn (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: DialogResult) => void) => {\n\t\tlet currentIdx = 0;\n\t\tlet view: DialogView = \"question\";\n\t\tlet inputMode: InputMode;\n\t\tlet noteTarget: string | undefined;\n\t\tlet notesSnapshot: { prior: QuestionAnswer | undefined } | undefined;\n\t\tlet dialogFocused = false;\n\t\tlet footerFocused = false;\n\t\tlet finished = false;\n\t\tconst cache = new WidthCachedRender();\n\t\tconst previewCache = new PreviewLinesCache();\n\t\tconst states = questions.map(() => newQuestionState());\n\t\tconst optionsByQuestion = questions.map((question) => displayOptions(question));\n\n\t\tconst editorTheme: EditorTheme = {\n\t\t\tborderColor: (s) => theme.fg(\"accent\", s),\n\t\t\tselectList: {\n\t\t\t\tselectedPrefix: (t) => theme.fg(\"accent\", t),\n\t\t\t\tselectedText: (t) => theme.fg(\"accent\", t),\n\t\t\t\tdescription: (t) => theme.fg(\"muted\", t),\n\t\t\t\tscrollInfo: (t) => theme.fg(\"dim\", t),\n\t\t\t\tnoMatch: (t) => theme.fg(\"warning\", t),\n\t\t\t},\n\t\t};\n\t\tconst editor = new Editor(tui, editorTheme);\n\n\t\tconst currentQuestion = () => questions[currentIdx];\n\t\tconst currentState = () => states[currentIdx];\n\t\tconst currentOptions = () => optionsByQuestion[currentIdx];\n\t\tconst keyMatches = (data: string, keybinding: Keybinding) => keybindings.matches(data, keybinding);\n\t\tconst keyLabel = (keybinding: Keybinding, fallback: string) =>\n\t\t\tkeybindings.getKeys(keybinding).join(\"/\") || fallback;\n\n\t\tfunction finish(result: DialogResult): void {\n\t\t\tif (finished) return;\n\t\t\tfinished = true;\n\t\t\tdone(result);\n\t\t}\n\n\t\tconst onAbort = () => finish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\tif (signal?.aborted) onAbort();\n\t\telse signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n\t\tfunction refresh(): void {\n\t\t\tcache.invalidate();\n\t\t\ttui.requestRender();\n\t\t}\n\n\t\tfunction syncEditorFocus(): void {\n\t\t\teditor.focused = dialogFocused && inputMode !== undefined;\n\t\t}\n\n\t\tfunction setCurrentIdx(index: number): void {\n\t\t\tcurrentIdx = Math.max(0, Math.min(questions.length - 1, index));\n\t\t\tview = \"question\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tnotesSnapshot = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction clearWarning(): void {\n\t\t\tcurrentState().warning = undefined;\n\t\t}\n\n\t\tfunction focusedOption(): DisplayOption | undefined {\n\t\t\treturn currentOptions()[currentState().optionIndex];\n\t\t}\n\n\t\tfunction showReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before reviewing your answers.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tview = \"review\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction advanceAfterAnswer(): void {\n\t\t\tif (questions.length === 1 && !currentQuestion().multiSelect) {\n\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing === undefined) {\n\t\t\t\tshowReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetCurrentIdx(missing);\n\t\t}\n\n\t\tfunction beginCustomInput(): void {\n\t\t\tconst state = currentState();\n\t\t\tinputMode = \"custom\";\n\t\t\tnoteTarget = undefined;\n\t\t\tconst existingSingle = state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined;\n\t\t\teditor.setText(state.customAnswer?.text ?? existingSingle ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction selectSingleOption(option: QuestionOption): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tstate.singleAnswer = {\n\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\tquestion: question.question,\n\t\t\t\theader: question.header,\n\t\t\t\tkind: \"option\",\n\t\t\t\tanswer: option.label,\n\t\t\t\t...(option.preview ? { preview: option.preview } : {}),\n\t\t\t};\n\t\t\tstate.warning = undefined;\n\t\t}\n\n\t\tfunction beginNotesInput(): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst option = focusedOption();\n\t\t\tif (!option || option.kind === \"other\") {\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (question.multiSelect && !state.multiSelected.has(option.optionIndex)) {\n\t\t\t\tstate.warning = \"Select the option first, then add notes.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!question.multiSelect) {\n\t\t\t\tnotesSnapshot = { prior: state.singleAnswer };\n\t\t\t\tselectSingleOption(option);\n\t\t\t}\n\t\t\tinputMode = \"notes\";\n\t\t\tnoteTarget = option.label;\n\t\t\teditor.setText(state.notesByOption.get(option.label) ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction recordSingle(option: DisplayOption): void {\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (currentState().singleAnswer?.kind === \"custom\") {\n\t\t\t\t\tadvanceAfterAnswer();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tselectSingleOption(option);\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction recordMulti(): void {\n\t\t\tconst state = currentState();\n\t\t\tif (!hasMultiAnswer(state)) {\n\t\t\t\tstate.warning = \"Select at least one option, type a custom answer, or cancel the questions.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tstate.warning = undefined;\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction toggleMultiOption(option: DisplayOption): void {\n\t\t\tconst state = currentState();\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (state.customAnswer) {\n\t\t\t\t\tstate.customAnswer.selected = !state.customAnswer.selected;\n\t\t\t\t\tclearWarning();\n\t\t\t\t} else {\n\t\t\t\t\tstate.warning = `Press ${keyLabel(\"tui.input.tab\", \"Tab\")} to type a custom answer.`;\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (state.multiSelected.has(option.optionIndex)) state.multiSelected.delete(option.optionIndex);\n\t\t\telse state.multiSelected.add(option.optionIndex);\n\t\t\tclearWarning();\n\t\t}\n\n\t\tfunction finishReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before submitting.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t}\n\n\t\teditor.onSubmit = (value) => {\n\t\t\tconst trimmed = value.trim();\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tif (trimmed.length > QUESTION_LIMITS.userTextChars) {\n\t\t\t\tstate.warning = `Keep notes and custom answers under ${QUESTION_LIMITS.userTextChars} characters.`;\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\tif (noteTarget) {\n\t\t\t\t\tif (trimmed) state.notesByOption.set(noteTarget, trimmed);\n\t\t\t\t\telse state.notesByOption.delete(noteTarget);\n\t\t\t\t}\n\t\t\t\tinputMode = undefined;\n\t\t\t\tnoteTarget = undefined;\n\t\t\t\tnotesSnapshot = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (inputMode === \"custom\") {\n\t\t\t\tif (!trimmed) {\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tstate.customAnswer = { text: trimmed, selected: true };\n\t\t\t\t\tstate.optionIndex = question.options.length;\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tstate.singleAnswer = {\n\t\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\t\tquestion: question.question,\n\t\t\t\t\theader: question.header,\n\t\t\t\t\tkind: \"custom\",\n\t\t\t\t\tanswer: trimmed,\n\t\t\t\t};\n\t\t\t\tinputMode = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\tadvanceAfterAnswer();\n\t\t\t}\n\t\t};\n\n\t\tfunction handleReviewInput(data: string): void {\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tfinishReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleQuestionInput(data: string): void {\n\t\t\tif (inputMode) {\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tif (inputMode === \"notes\" && notesSnapshot) {\n\t\t\t\t\t\tcurrentState().singleAnswer = notesSnapshot.prior;\n\t\t\t\t\t}\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\tnoteTarget = undefined;\n\t\t\t\t\tnotesSnapshot = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\teditor.handleInput(data);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tif (currentIdx > 0) setCurrentIdx(currentIdx - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.editor.cursorRight\")) {\n\t\t\t\tif (currentIdx < questions.length - 1) setCurrentIdx(currentIdx + 1);\n\t\t\t\telse if (firstUnanswered(states) === undefined) showReview();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst state = currentState();\n\t\t\tconst options = currentOptions();\n\t\t\tif (keyMatches(data, \"tui.select.up\")) {\n\t\t\t\tif (footerFocused) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = options.length - 1;\n\t\t\t\t} else if (state.optionIndex > 0) {\n\t\t\t\t\tstate.optionIndex--;\n\t\t\t\t}\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.down\")) {\n\t\t\t\tif (footerFocused) return;\n\t\t\t\tif (state.optionIndex < options.length - 1) state.optionIndex++;\n\t\t\t\telse footerFocused = true;\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.input.tab\")) {\n\t\t\t\tif (!footerFocused) beginNotesInput();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (data.length === 1 && data >= \"1\" && data <= \"9\") {\n\t\t\t\tconst index = data.charCodeAt(0) - 49;\n\t\t\t\tconst target = options[index];\n\t\t\t\tif (target) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = index;\n\t\t\t\t\tclearWarning();\n\t\t\t\t\tif (currentQuestion().multiSelect) {\n\t\t\t\t\t\ttoggleMultiOption(target);\n\t\t\t\t\t\trefresh();\n\t\t\t\t\t} else {\n\t\t\t\t\t\trecordSingle(target);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (footerFocused) {\n\t\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"needs_clarification\" });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst option = options[state.optionIndex];\n\t\t\tif (question.multiSelect && keyMatches(data, \"app.list.toggle\")) {\n\t\t\t\tif (option) toggleMultiOption(option);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tif (option?.kind === \"other\" && !state.customAnswer) {\n\t\t\t\t\t\tbeginCustomInput();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\trecordMulti();\n\t\t\t\t} else if (option) recordSingle(option);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t}\n\t\t}\n\n\t\tfunction handleInput(data: string): void {\n\t\t\tif (view === \"review\") handleReviewInput(data);\n\t\t\telse handleQuestionInput(data);\n\t\t}\n\n\t\tfunction render(width: number): string[] {\n\t\t\treturn cache.get(width, tui.terminal.rows, compute);\n\t\t}\n\n\t\tfunction renderTabs(renderWidth: number, lines: string[]): void {\n\t\t\tif (questions.length < 2) return;\n\t\t\tconst tabCount = questions.length + 1;\n\t\t\tconst headerWidth = Math.max(4, Math.floor((renderWidth - tabCount * 5) / tabCount));\n\t\t\tconst tabs = [\n\t\t\t\t...questions.map((question, index) => {\n\t\t\t\t\tconst answered = hasAnswer(states[index]);\n\t\t\t\t\tconst label = ` ${answered ? \"■\" : \"□\"} ${truncateToWidth(question.header, headerWidth)} `;\n\t\t\t\t\tif (view === \"question\" && index === currentIdx) {\n\t\t\t\t\t\treturn theme.bg(\"selectedBg\", theme.fg(answered ? \"success\" : \"text\", label));\n\t\t\t\t\t}\n\t\t\t\t\treturn theme.fg(answered ? \"success\" : \"muted\", label);\n\t\t\t\t}),\n\t\t\t\tview === \"review\"\n\t\t\t\t\t? theme.bg(\"selectedBg\", theme.fg(\"text\", \" ✓ Submit \"))\n\t\t\t\t\t: theme.fg(firstUnanswered(states) === undefined ? \"success\" : \"dim\", \" ✓ Submit \"),\n\t\t\t].join(\" \");\n\t\t\twrapWithPrefix(\" \", tabs, renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t}\n\n\t\tfunction previewLines(previewText: string, width: number, terminalRows: number): string[] {\n\t\t\tconst maxLines = Math.max(4, Math.min(PREVIEW_MAX_LINES, terminalRows - PREVIEW_CHROME_ROWS));\n\t\t\treturn previewCache.get(previewText, width, maxLines, () => {\n\t\t\t\tconst markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());\n\t\t\t\tconst rendered = markdown.render(Math.max(1, width));\n\t\t\t\tif (rendered.length <= maxLines) return rendered;\n\t\t\t\treturn [\n\t\t\t\t\t...rendered.slice(0, maxLines),\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\ttruncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width)),\n\t\t\t\t\t),\n\t\t\t\t];\n\t\t\t});\n\t\t}\n\n\t\t/** Multi-select checkbox marker; the other row shows no box until a custom answer exists. */\n\t\tfunction multiMarker(option: DisplayOption, checked: boolean, hasCustomAnswer: boolean): string {\n\t\t\tif (option.kind === \"other\" && !hasCustomAnswer) return \" \";\n\t\t\treturn checked ? theme.fg(\"success\", \"[x]\") : theme.fg(\"dim\", \"[ ]\");\n\t\t}\n\n\t\tfunction renderReview(renderWidth: number, lines: string[]): void {\n\t\t\twrapWithPrefix(\" \", theme.fg(\"accent\", \"Review answers\"), renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t\tfor (const answer of orderedAnswers(questions, states)) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\"text\", `${answer.questionIndex + 1}. ${answer.question}`),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t\tconst response =\n\t\t\t\t\tanswer.kind === \"multi\"\n\t\t\t\t\t\t? (answer.selected?.join(\", \") ?? \"(no input)\")\n\t\t\t\t\t\t: (answer.answer ?? \"(no input)\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"success\", response), renderWidth, lines);\n\t\t\t\tfor (const note of answer.notes ?? []) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", `Note for ${note.option}: ${note.text}`), renderWidth, lines);\n\t\t\t\t}\n\t\t\t\tlines.push(\"\");\n\t\t\t}\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\ttheme.fg(\n\t\t\t\t\t\"dim\",\n\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to submit • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to edit the last question`,\n\t\t\t\t),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t}\n\n\t\tfunction compute(width: number, terminalRows: number): string[] {\n\t\t\tconst lines: string[] = [];\n\t\t\tconst renderWidth = Math.max(1, width);\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\trenderTabs(renderWidth, lines);\n\n\t\t\tif (view === \"review\") {\n\t\t\t\trenderReview(renderWidth, lines);\n\t\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\t\treturn lines;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst options = currentOptions();\n\t\t\tconst isMulti = question.multiSelect === true;\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\t`${theme.fg(\"accent\", question.header)} ${theme.fg(\"text\", question.question)}`,\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tlines.push(\"\");\n\n\t\t\tconst optionLines: string[] = [];\n\t\t\tconst hasPreview = !isMulti && options.some((option) => option.kind === \"option\" && option.preview);\n\t\t\tconst showPreviewSideBySide = hasPreview && renderWidth >= 60;\n\t\t\tconst listWidth = showPreviewSideBySide ? Math.max(20, Math.floor(renderWidth * 0.4)) : renderWidth;\n\n\t\t\tfor (let index = 0; index < options.length; index++) {\n\t\t\t\tconst option = options[index];\n\t\t\t\tconst focused = !footerFocused && index === state.optionIndex;\n\t\t\t\tconst checked =\n\t\t\t\t\tisMulti &&\n\t\t\t\t\t((option.kind === \"option\" && state.multiSelected.has(option.optionIndex)) ||\n\t\t\t\t\t\t(option.kind === \"other\" && state.customAnswer?.selected === true));\n\t\t\t\tconst selectedSingle =\n\t\t\t\t\t!isMulti &&\n\t\t\t\t\t(option.kind === \"other\"\n\t\t\t\t\t\t? state.singleAnswer?.kind === \"custom\"\n\t\t\t\t\t\t: state.singleAnswer?.answer === option.label);\n\t\t\t\tconst marker = isMulti ? multiMarker(option, checked, state.customAnswer !== undefined) : \"\";\n\t\t\t\tconst focusArrow = focused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\t\tconst note =\n\t\t\t\t\toption.kind === \"option\" && state.notesByOption.has(option.label) ? theme.fg(\"success\", \" +note\") : \"\";\n\t\t\t\tconst customText =\n\t\t\t\t\toption.kind === \"other\"\n\t\t\t\t\t\t? (state.customAnswer?.text ??\n\t\t\t\t\t\t\t(state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined))\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst labelText = customText ? `${option.label} ✎ ${customText}` : option.label;\n\t\t\t\tconst label = `${index + 1}. ${labelText}${selectedSingle ? \" ✓\" : \"\"}${note}`;\n\t\t\t\tconst color = focused ? \"accent\" : selectedSingle || checked ? \"success\" : \"text\";\n\t\t\t\twrapWithPrefix(`${focusArrow} ${marker} `, theme.fg(color, label), listWidth, optionLines);\n\t\t\t\tif (option.kind === \"option\" && option.description) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", option.description), listWidth, optionLines);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (inputMode) {\n\t\t\t\toptionLines.push(\"\");\n\t\t\t\tconst label = inputMode === \"notes\" ? `Notes for ${noteTarget ?? \"option\"}:` : \"Your answer:\";\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", label), listWidth, optionLines);\n\t\t\t\tfor (const line of editor.render(Math.max(1, listWidth - 2))) optionLines.push(` ${line}`);\n\t\t\t}\n\n\t\t\tif (showPreviewSideBySide) {\n\t\t\t\tconst gap = 2;\n\t\t\t\tconst rightWidth = renderWidth - listWidth - gap;\n\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\tconst rightLines = previewText\n\t\t\t\t\t? previewLines(previewText, rightWidth, terminalRows)\n\t\t\t\t\t: [theme.fg(\"dim\", \"(no preview)\")];\n\t\t\t\tconst maxRows = Math.max(optionLines.length, rightLines.length);\n\t\t\t\tconst pad = \" \".repeat(gap);\n\t\t\t\tfor (let row = 0; row < maxRows; row++) {\n\t\t\t\t\tconst left = row < optionLines.length ? optionLines[row] : \"\";\n\t\t\t\t\tconst leftPadded = left + \" \".repeat(Math.max(0, listWidth - visibleWidth(left)));\n\t\t\t\t\tconst right = row < rightLines.length ? rightLines[row] : \"\";\n\t\t\t\t\tlines.push(`${leftPadded}${pad}${right}`);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlines.push(...optionLines);\n\t\t\t\tif (hasPreview) {\n\t\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\t\tif (previewText) {\n\t\t\t\t\t\tlines.push(\"\");\n\t\t\t\t\t\tlines.push(ruleBorder(theme, renderWidth, \"dim\"));\n\t\t\t\t\t\tlines.push(...previewLines(previewText, renderWidth, terminalRows));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlines.push(\"\");\n\t\t\tconst chatPrefix = footerFocused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\twrapWithPrefix(\n\t\t\t\t`${chatPrefix} `,\n\t\t\t\ttheme.fg(footerFocused ? \"accent\" : \"text\", \"Chat about this\"),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tif (state.warning) {\n\t\t\t\tlines.push(\"\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"warning\", state.warning), renderWidth, lines);\n\t\t\t}\n\t\t\tlines.push(\"\");\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to save notes • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to go back`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (inputMode === \"custom\") {\n\t\t\t\tconst hint = isMulti ? \"to save custom answer\" : \"to continue\";\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} ${hint} • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to go back`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (footerFocused) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to discuss • ${keyLabel(\"tui.select.up\", \"Up\")} to return to options`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (isMulti) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"app.list.toggle\", \"Space\")} to toggle • ${keyLabel(\"tui.input.tab\", \"Tab\")} for notes/custom • ${keyLabel(\"tui.select.confirm\", \"Enter\")} to continue • ${keyLabel(\"tui.editor.cursorLeft\", \"Left\")}/${keyLabel(\"tui.editor.cursorRight\", \"Right\")} questions • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to cancel`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.input.tab\", \"Tab\")} for notes/custom • ${keyLabel(\"tui.select.confirm\", \"Enter\")} to select • ${keyLabel(\"tui.editor.cursorLeft\", \"Left\")}/${keyLabel(\"tui.editor.cursorRight\", \"Right\")} questions • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to cancel`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t}\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\treturn lines;\n\t\t}\n\n\t\treturn {\n\t\t\tget focused() {\n\t\t\t\treturn dialogFocused;\n\t\t\t},\n\t\t\tset focused(value: boolean) {\n\t\t\t\tdialogFocused = value;\n\t\t\t\tsyncEditorFocus();\n\t\t\t},\n\t\t\trender,\n\t\t\tinvalidate: () => {\n\t\t\t\tcache.invalidate();\n\t\t\t},\n\t\t\thandleInput,\n\t\t\tdispose() {\n\t\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\t},\n\t\t};\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../../../src/extensions/question/dialog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,GAAG,EAAiC,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAc,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAEhF,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AAYtF,OAAO,KAAK,EAAE,YAAY,EAA4B,QAAQ,EAAkC,MAAM,YAAY,CAAC;AAanH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,QAAQ,EAAE,EAAE,MAAM,CAAC,EAAE,WAAW;;;;;;EAiqB/E","sourcesContent":["import { Editor, type EditorTheme, Markdown, type TUI, truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport type { Keybinding, KeybindingsManager } from \"../../core/keybindings.ts\";\nimport { keyLabel as configuredKeyLabel } from \"../../modes/interactive/components/keybinding-hints.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport { ruleBorder, wrapWithPrefix } from \"./dialog-primitives.ts\";\nimport { QUESTION_LIMITS } from \"./limits.ts\";\nimport { PreviewLinesCache, WidthCachedRender } from \"./render-cache.ts\";\nimport {\n\tdisplayOptions,\n\tfirstUnanswered,\n\thasAnswer,\n\thasMultiAnswer,\n\tnewQuestionState,\n\torderedAnswers,\n} from \"./state.ts\";\nimport type { DialogResult, DisplayOption, InputMode, Question, QuestionAnswer, QuestionOption } from \"./types.ts\";\n\ntype DialogView = \"question\" | \"review\";\n\nconst PREVIEW_MAX_LINES = 16;\n/**\n * Estimated non-preview rows in the stacked (narrow) layout: top/bottom rules,\n * tab strip + blank, question line + blank, up to 5 option rows with\n * descriptions, the \"Chat about this\" row, warning slot, and the hint line.\n * Reserved so the preview clamps short of the terminal height.\n */\nconst PREVIEW_CHROME_ROWS = 18;\n\nexport function createQuestionDialog(questions: Question[], signal?: AbortSignal) {\n\treturn (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: DialogResult) => void) => {\n\t\tlet currentIdx = 0;\n\t\tlet view: DialogView = \"question\";\n\t\tlet inputMode: InputMode;\n\t\tlet noteTarget: string | undefined;\n\t\tlet notesSnapshot: { prior: QuestionAnswer | undefined } | undefined;\n\t\tlet dialogFocused = false;\n\t\tlet footerFocused = false;\n\t\tlet finished = false;\n\t\tconst cache = new WidthCachedRender();\n\t\tconst previewCache = new PreviewLinesCache();\n\t\tconst states = questions.map(() => newQuestionState());\n\t\tconst optionsByQuestion = questions.map((question) => displayOptions(question));\n\n\t\tconst editorTheme: EditorTheme = {\n\t\t\tborderColor: (s) => theme.fg(\"accent\", s),\n\t\t\tselectList: {\n\t\t\t\tselectedPrefix: (t) => theme.fg(\"accent\", t),\n\t\t\t\tselectedText: (t) => theme.fg(\"accent\", t),\n\t\t\t\tdescription: (t) => theme.fg(\"muted\", t),\n\t\t\t\tscrollInfo: (t) => theme.fg(\"dim\", t),\n\t\t\t\tnoMatch: (t) => theme.fg(\"warning\", t),\n\t\t\t},\n\t\t};\n\t\tconst editor = new Editor(tui, editorTheme);\n\n\t\tconst currentQuestion = () => questions[currentIdx];\n\t\tconst currentState = () => states[currentIdx];\n\t\tconst currentOptions = () => optionsByQuestion[currentIdx];\n\t\tconst keyMatches = (data: string, keybinding: Keybinding) => keybindings.matches(data, keybinding);\n\t\tconst keyLabel = (keybinding: Keybinding) => configuredKeyLabel(keybinding, { keybindings });\n\t\tconst keyAction = (keybinding: Keybinding, action: string) => {\n\t\t\tconst label = keyLabel(keybinding);\n\t\t\treturn label ? `${label} ${action}` : \"\";\n\t\t};\n\t\t// Editor reads the global TUI manager internally, while dialog controls use the injected manager.\n\t\tconst editorSubmitAction = (action: string) => {\n\t\t\tconst label = configuredKeyLabel(\"tui.input.submit\");\n\t\t\treturn label ? `${label} ${action}` : \"\";\n\t\t};\n\t\tconst keyGroupAction = (bindings: Keybinding[], action: string) => {\n\t\t\tconst labels = bindings.map(keyLabel).filter(Boolean).join(\"/\");\n\t\t\treturn labels ? `${labels} ${action}` : \"\";\n\t\t};\n\t\tconst joinHints = (...hints: string[]) => hints.filter(Boolean).join(\" • \");\n\n\t\tfunction finish(result: DialogResult): void {\n\t\t\tif (finished) return;\n\t\t\tfinished = true;\n\t\t\tdone(result);\n\t\t}\n\n\t\tconst onAbort = () => finish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\tif (signal?.aborted) onAbort();\n\t\telse signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n\t\tfunction refresh(): void {\n\t\t\tcache.invalidate();\n\t\t\ttui.requestRender();\n\t\t}\n\n\t\tfunction syncEditorFocus(): void {\n\t\t\teditor.focused = dialogFocused && inputMode !== undefined;\n\t\t}\n\n\t\tfunction setCurrentIdx(index: number): void {\n\t\t\tcurrentIdx = Math.max(0, Math.min(questions.length - 1, index));\n\t\t\tview = \"question\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tnotesSnapshot = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction clearWarning(): void {\n\t\t\tcurrentState().warning = undefined;\n\t\t}\n\n\t\tfunction focusedOption(): DisplayOption | undefined {\n\t\t\treturn currentOptions()[currentState().optionIndex];\n\t\t}\n\n\t\tfunction showReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before reviewing your answers.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tview = \"review\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction advanceAfterAnswer(): void {\n\t\t\tif (questions.length === 1 && !currentQuestion().multiSelect) {\n\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing === undefined) {\n\t\t\t\tshowReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetCurrentIdx(missing);\n\t\t}\n\n\t\tfunction beginCustomInput(): void {\n\t\t\tconst state = currentState();\n\t\t\tinputMode = \"custom\";\n\t\t\tnoteTarget = undefined;\n\t\t\tconst existingSingle = state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined;\n\t\t\teditor.setText(state.customAnswer?.text ?? existingSingle ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction selectSingleOption(option: QuestionOption): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tstate.singleAnswer = {\n\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\tquestion: question.question,\n\t\t\t\theader: question.header,\n\t\t\t\tkind: \"option\",\n\t\t\t\tanswer: option.label,\n\t\t\t\t...(option.preview ? { preview: option.preview } : {}),\n\t\t\t};\n\t\t\tstate.warning = undefined;\n\t\t}\n\n\t\tfunction beginNotesInput(): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst option = focusedOption();\n\t\t\tif (!option || option.kind === \"other\") {\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (question.multiSelect && !state.multiSelected.has(option.optionIndex)) {\n\t\t\t\tstate.warning = \"Select the option first, then add notes.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!question.multiSelect) {\n\t\t\t\tnotesSnapshot = { prior: state.singleAnswer };\n\t\t\t\tselectSingleOption(option);\n\t\t\t}\n\t\t\tinputMode = \"notes\";\n\t\t\tnoteTarget = option.label;\n\t\t\teditor.setText(state.notesByOption.get(option.label) ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction recordSingle(option: DisplayOption): void {\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (currentState().singleAnswer?.kind === \"custom\") {\n\t\t\t\t\tadvanceAfterAnswer();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tselectSingleOption(option);\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction recordMulti(): void {\n\t\t\tconst state = currentState();\n\t\t\tif (!hasMultiAnswer(state)) {\n\t\t\t\tstate.warning = \"Select at least one option, type a custom answer, or cancel the questions.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tstate.warning = undefined;\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction toggleMultiOption(option: DisplayOption): void {\n\t\t\tconst state = currentState();\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (state.customAnswer) {\n\t\t\t\t\tstate.customAnswer.selected = !state.customAnswer.selected;\n\t\t\t\t\tclearWarning();\n\t\t\t\t} else {\n\t\t\t\t\tconst tabKey = keyLabel(\"tui.input.tab\");\n\t\t\t\t\tstate.warning = tabKey\n\t\t\t\t\t\t? `Press ${tabKey} to type a custom answer.`\n\t\t\t\t\t\t: \"Choose Type something to type a custom answer.\";\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (state.multiSelected.has(option.optionIndex)) state.multiSelected.delete(option.optionIndex);\n\t\t\telse state.multiSelected.add(option.optionIndex);\n\t\t\tclearWarning();\n\t\t}\n\n\t\tfunction finishReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before submitting.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t}\n\n\t\teditor.onSubmit = (value) => {\n\t\t\tconst trimmed = value.trim();\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tif (trimmed.length > QUESTION_LIMITS.userTextChars) {\n\t\t\t\tstate.warning = `Keep notes and custom answers under ${QUESTION_LIMITS.userTextChars} characters.`;\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\tif (noteTarget) {\n\t\t\t\t\tif (trimmed) state.notesByOption.set(noteTarget, trimmed);\n\t\t\t\t\telse state.notesByOption.delete(noteTarget);\n\t\t\t\t}\n\t\t\t\tinputMode = undefined;\n\t\t\t\tnoteTarget = undefined;\n\t\t\t\tnotesSnapshot = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (inputMode === \"custom\") {\n\t\t\t\tif (!trimmed) {\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tstate.customAnswer = { text: trimmed, selected: true };\n\t\t\t\t\tstate.optionIndex = question.options.length;\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tstate.singleAnswer = {\n\t\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\t\tquestion: question.question,\n\t\t\t\t\theader: question.header,\n\t\t\t\t\tkind: \"custom\",\n\t\t\t\t\tanswer: trimmed,\n\t\t\t\t};\n\t\t\t\tinputMode = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\tadvanceAfterAnswer();\n\t\t\t}\n\t\t};\n\n\t\tfunction handleReviewInput(data: string): void {\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tfinishReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleQuestionInput(data: string): void {\n\t\t\tif (inputMode) {\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tif (inputMode === \"notes\" && notesSnapshot) {\n\t\t\t\t\t\tcurrentState().singleAnswer = notesSnapshot.prior;\n\t\t\t\t\t}\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\tnoteTarget = undefined;\n\t\t\t\t\tnotesSnapshot = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\teditor.handleInput(data);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tif (currentIdx > 0) setCurrentIdx(currentIdx - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.editor.cursorRight\")) {\n\t\t\t\tif (currentIdx < questions.length - 1) setCurrentIdx(currentIdx + 1);\n\t\t\t\telse if (firstUnanswered(states) === undefined) showReview();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst state = currentState();\n\t\t\tconst options = currentOptions();\n\t\t\tif (keyMatches(data, \"tui.select.up\")) {\n\t\t\t\tif (footerFocused) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = options.length - 1;\n\t\t\t\t} else if (state.optionIndex > 0) {\n\t\t\t\t\tstate.optionIndex--;\n\t\t\t\t}\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.down\")) {\n\t\t\t\tif (footerFocused) return;\n\t\t\t\tif (state.optionIndex < options.length - 1) state.optionIndex++;\n\t\t\t\telse footerFocused = true;\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.input.tab\")) {\n\t\t\t\tif (!footerFocused) beginNotesInput();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (data.length === 1 && data >= \"1\" && data <= \"9\") {\n\t\t\t\tconst index = data.charCodeAt(0) - 49;\n\t\t\t\tconst target = options[index];\n\t\t\t\tif (target) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = index;\n\t\t\t\t\tclearWarning();\n\t\t\t\t\tif (currentQuestion().multiSelect) {\n\t\t\t\t\t\ttoggleMultiOption(target);\n\t\t\t\t\t\trefresh();\n\t\t\t\t\t} else {\n\t\t\t\t\t\trecordSingle(target);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (footerFocused) {\n\t\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"needs_clarification\" });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst option = options[state.optionIndex];\n\t\t\tif (question.multiSelect && keyMatches(data, \"app.list.toggle\")) {\n\t\t\t\tif (option) toggleMultiOption(option);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tif (option?.kind === \"other\" && !state.customAnswer) {\n\t\t\t\t\t\tbeginCustomInput();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\trecordMulti();\n\t\t\t\t} else if (option) recordSingle(option);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t}\n\t\t}\n\n\t\tfunction handleInput(data: string): void {\n\t\t\tif (view === \"review\") handleReviewInput(data);\n\t\t\telse handleQuestionInput(data);\n\t\t}\n\n\t\tfunction render(width: number): string[] {\n\t\t\treturn cache.get(width, tui.terminal.rows, compute);\n\t\t}\n\n\t\tfunction renderTabs(renderWidth: number, lines: string[]): void {\n\t\t\tif (questions.length < 2) return;\n\t\t\tconst tabCount = questions.length + 1;\n\t\t\tconst headerWidth = Math.max(4, Math.floor((renderWidth - tabCount * 5) / tabCount));\n\t\t\tconst tabs = [\n\t\t\t\t...questions.map((question, index) => {\n\t\t\t\t\tconst answered = hasAnswer(states[index]);\n\t\t\t\t\tconst label = ` ${answered ? \"■\" : \"□\"} ${truncateToWidth(question.header, headerWidth)} `;\n\t\t\t\t\tif (view === \"question\" && index === currentIdx) {\n\t\t\t\t\t\treturn theme.bg(\"selectedBg\", theme.fg(answered ? \"success\" : \"text\", label));\n\t\t\t\t\t}\n\t\t\t\t\treturn theme.fg(answered ? \"success\" : \"muted\", label);\n\t\t\t\t}),\n\t\t\t\tview === \"review\"\n\t\t\t\t\t? theme.bg(\"selectedBg\", theme.fg(\"text\", \" ✓ Submit \"))\n\t\t\t\t\t: theme.fg(firstUnanswered(states) === undefined ? \"success\" : \"dim\", \" ✓ Submit \"),\n\t\t\t].join(\" \");\n\t\t\twrapWithPrefix(\" \", tabs, renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t}\n\n\t\tfunction previewLines(previewText: string, width: number, terminalRows: number): string[] {\n\t\t\tconst maxLines = Math.max(4, Math.min(PREVIEW_MAX_LINES, terminalRows - PREVIEW_CHROME_ROWS));\n\t\t\treturn previewCache.get(previewText, width, maxLines, () => {\n\t\t\t\tconst markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());\n\t\t\t\tconst rendered = markdown.render(Math.max(1, width));\n\t\t\t\tif (rendered.length <= maxLines) return rendered;\n\t\t\t\treturn [\n\t\t\t\t\t...rendered.slice(0, maxLines),\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\ttruncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width)),\n\t\t\t\t\t),\n\t\t\t\t];\n\t\t\t});\n\t\t}\n\n\t\t/** Multi-select checkbox marker; the other row shows no box until a custom answer exists. */\n\t\tfunction multiMarker(option: DisplayOption, checked: boolean, hasCustomAnswer: boolean): string {\n\t\t\tif (option.kind === \"other\" && !hasCustomAnswer) return \" \";\n\t\t\treturn checked ? theme.fg(\"success\", \"[x]\") : theme.fg(\"dim\", \"[ ]\");\n\t\t}\n\n\t\tfunction renderReview(renderWidth: number, lines: string[]): void {\n\t\t\twrapWithPrefix(\" \", theme.fg(\"accent\", \"Review answers\"), renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t\tfor (const answer of orderedAnswers(questions, states)) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\"text\", `${answer.questionIndex + 1}. ${answer.question}`),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t\tconst response =\n\t\t\t\t\tanswer.kind === \"multi\"\n\t\t\t\t\t\t? (answer.selected?.join(\", \") ?? \"(no input)\")\n\t\t\t\t\t\t: (answer.answer ?? \"(no input)\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"success\", response), renderWidth, lines);\n\t\t\t\tfor (const note of answer.notes ?? []) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", `Note for ${note.option}: ${note.text}`), renderWidth, lines);\n\t\t\t\t}\n\t\t\t\tlines.push(\"\");\n\t\t\t}\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\ttheme.fg(\n\t\t\t\t\t\"dim\",\n\t\t\t\t\tjoinHints(\n\t\t\t\t\t\tkeyAction(\"tui.select.confirm\", \"submit\"),\n\t\t\t\t\t\tkeyAction(\"tui.select.cancel\", \"edit last question\"),\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t}\n\n\t\tfunction compute(width: number, terminalRows: number): string[] {\n\t\t\tconst lines: string[] = [];\n\t\t\tconst renderWidth = Math.max(1, width);\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\trenderTabs(renderWidth, lines);\n\n\t\t\tif (view === \"review\") {\n\t\t\t\trenderReview(renderWidth, lines);\n\t\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\t\treturn lines;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst options = currentOptions();\n\t\t\tconst isMulti = question.multiSelect === true;\n\t\t\tconst digitRange = `1-${Math.min(9, options.length)}`;\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\t`${theme.fg(\"accent\", question.header)} ${theme.fg(\"text\", question.question)}`,\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tlines.push(\"\");\n\n\t\t\tconst optionLines: string[] = [];\n\t\t\tconst hasPreview = !isMulti && options.some((option) => option.kind === \"option\" && option.preview);\n\t\t\tconst showPreviewSideBySide = hasPreview && renderWidth >= 60;\n\t\t\tconst listWidth = showPreviewSideBySide ? Math.max(20, Math.floor(renderWidth * 0.4)) : renderWidth;\n\n\t\t\tfor (let index = 0; index < options.length; index++) {\n\t\t\t\tconst option = options[index];\n\t\t\t\tconst focused = !footerFocused && index === state.optionIndex;\n\t\t\t\tconst checked =\n\t\t\t\t\tisMulti &&\n\t\t\t\t\t((option.kind === \"option\" && state.multiSelected.has(option.optionIndex)) ||\n\t\t\t\t\t\t(option.kind === \"other\" && state.customAnswer?.selected === true));\n\t\t\t\tconst selectedSingle =\n\t\t\t\t\t!isMulti &&\n\t\t\t\t\t(option.kind === \"other\"\n\t\t\t\t\t\t? state.singleAnswer?.kind === \"custom\"\n\t\t\t\t\t\t: state.singleAnswer?.answer === option.label);\n\t\t\t\tconst marker = isMulti ? multiMarker(option, checked, state.customAnswer !== undefined) : \" \";\n\t\t\t\tconst focusArrow = focused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\t\tconst note =\n\t\t\t\t\toption.kind === \"option\" && state.notesByOption.has(option.label) ? theme.fg(\"success\", \" +note\") : \"\";\n\t\t\t\tconst customText =\n\t\t\t\t\toption.kind === \"other\"\n\t\t\t\t\t\t? (state.customAnswer?.text ??\n\t\t\t\t\t\t\t(state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined))\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst labelText = customText ? `${option.label} ✎ ${customText}` : option.label;\n\t\t\t\tconst label = `${index + 1}. ${labelText}${selectedSingle ? \" ✓\" : \"\"}${note}`;\n\t\t\t\tconst color = focused ? \"accent\" : selectedSingle || checked ? \"success\" : \"text\";\n\t\t\t\twrapWithPrefix(`${focusArrow} ${marker} `, theme.fg(color, label), listWidth, optionLines);\n\t\t\t\tif (option.kind === \"option\" && option.description) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", option.description), listWidth, optionLines);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (inputMode) {\n\t\t\t\toptionLines.push(\"\");\n\t\t\t\tconst label = inputMode === \"notes\" ? `Notes for ${noteTarget ?? \"option\"}:` : \"Your answer:\";\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", label), listWidth, optionLines);\n\t\t\t\tfor (const line of editor.render(Math.max(1, listWidth - 2))) optionLines.push(` ${line}`);\n\t\t\t}\n\n\t\t\tif (showPreviewSideBySide) {\n\t\t\t\tconst gap = 2;\n\t\t\t\tconst rightWidth = renderWidth - listWidth - gap;\n\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\tconst rightLines = previewText\n\t\t\t\t\t? previewLines(previewText, rightWidth, terminalRows)\n\t\t\t\t\t: [theme.fg(\"dim\", \"(no preview)\")];\n\t\t\t\tconst maxRows = Math.max(optionLines.length, rightLines.length);\n\t\t\t\tconst pad = \" \".repeat(gap);\n\t\t\t\tfor (let row = 0; row < maxRows; row++) {\n\t\t\t\t\tconst left = row < optionLines.length ? optionLines[row] : \"\";\n\t\t\t\t\tconst leftPadded = left + \" \".repeat(Math.max(0, listWidth - visibleWidth(left)));\n\t\t\t\t\tconst right = row < rightLines.length ? rightLines[row] : \"\";\n\t\t\t\t\tlines.push(`${leftPadded}${pad}${right}`);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlines.push(...optionLines);\n\t\t\t\tif (hasPreview) {\n\t\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\t\tif (previewText) {\n\t\t\t\t\t\tlines.push(\"\");\n\t\t\t\t\t\tlines.push(ruleBorder(theme, renderWidth, \"dim\"));\n\t\t\t\t\t\tlines.push(...previewLines(previewText, renderWidth, terminalRows));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlines.push(\"\");\n\t\t\tconst chatPrefix = footerFocused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\twrapWithPrefix(\n\t\t\t\t`${chatPrefix} `,\n\t\t\t\ttheme.fg(footerFocused ? \"accent\" : \"text\", \"Chat about this\"),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tif (state.warning) {\n\t\t\t\tlines.push(\"\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"warning\", state.warning), renderWidth, lines);\n\t\t\t}\n\t\t\tlines.push(\"\");\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\"dim\", joinHints(editorSubmitAction(\"save notes\"), keyAction(\"tui.select.cancel\", \"back\"))),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (inputMode === \"custom\") {\n\t\t\t\tconst hint = isMulti ? \"save custom answer\" : \"continue\";\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\"dim\", joinHints(editorSubmitAction(hint), keyAction(\"tui.select.cancel\", \"back\"))),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (footerFocused) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\tjoinHints(\n\t\t\t\t\t\t\tkeyAction(\"tui.select.confirm\", \"discuss\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.select.up\", \"return to options\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.select.cancel\", \"cancel\"),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (isMulti) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\tjoinHints(\n\t\t\t\t\t\t\t`${digitRange} toggle`,\n\t\t\t\t\t\t\tkeyAction(\"app.list.toggle\", \"toggle focused\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.input.tab\", \"notes/custom\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.select.confirm\", \"continue\"),\n\t\t\t\t\t\t\tkeyGroupAction([\"tui.editor.cursorLeft\", \"tui.editor.cursorRight\"], \"questions\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.select.cancel\", \"cancel\"),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\tjoinHints(\n\t\t\t\t\t\t\t`${digitRange} select`,\n\t\t\t\t\t\t\tkeyAction(\"tui.input.tab\", \"notes/custom\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.select.confirm\", \"select\"),\n\t\t\t\t\t\t\tkeyGroupAction([\"tui.editor.cursorLeft\", \"tui.editor.cursorRight\"], \"questions\"),\n\t\t\t\t\t\t\tkeyAction(\"tui.select.cancel\", \"cancel\"),\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t}\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\treturn lines;\n\t\t}\n\n\t\treturn {\n\t\t\tget focused() {\n\t\t\t\treturn dialogFocused;\n\t\t\t},\n\t\t\tset focused(value: boolean) {\n\t\t\t\tdialogFocused = value;\n\t\t\t\tsyncEditorFocus();\n\t\t\t},\n\t\t\trender,\n\t\t\tinvalidate: () => {\n\t\t\t\tcache.invalidate();\n\t\t\t},\n\t\t\thandleInput,\n\t\t\tdispose() {\n\t\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\t},\n\t\t};\n\t};\n}\n"]}
|