@oh-my-pi/pi-coding-agent 15.9.67 → 15.10.1
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 +136 -0
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/cli/dry-balance-cli.d.ts +15 -1
- package/dist/types/cli/gallery-cli.d.ts +43 -0
- package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
- package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
- package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
- package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
- package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
- package/dist/types/cli/gallery-screenshot.d.ts +35 -0
- package/dist/types/commands/gallery.d.ts +47 -0
- package/dist/types/commit/analysis/conventional.d.ts +2 -2
- package/dist/types/commit/analysis/summary.d.ts +2 -2
- package/dist/types/commit/changelog/generate.d.ts +2 -2
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/index.d.ts +3 -3
- package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
- package/dist/types/commit/model-selection.d.ts +10 -4
- package/dist/types/config/api-key-resolver.d.ts +34 -0
- package/dist/types/config/keybindings.d.ts +6 -1
- package/dist/types/config/model-id-affixes.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +25 -2
- package/dist/types/config/settings-schema.d.ts +41 -6
- package/dist/types/dap/config.d.ts +14 -1
- package/dist/types/dap/types.d.ts +10 -0
- package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
- package/dist/types/lsp/types.d.ts +10 -0
- package/dist/types/lsp/utils.d.ts +3 -2
- package/dist/types/main.d.ts +3 -2
- package/dist/types/memory-backend/index.d.ts +2 -1
- package/dist/types/memory-backend/resolve.d.ts +1 -1
- package/dist/types/memory-backend/types.d.ts +1 -1
- package/dist/types/modes/components/chat-block.d.ts +64 -0
- package/dist/types/modes/components/custom-editor.d.ts +5 -1
- package/dist/types/modes/components/overlay-box.d.ts +17 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
- package/dist/types/modes/components/plan-toc.d.ts +41 -0
- package/dist/types/modes/components/read-tool-group.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +18 -0
- package/dist/types/modes/components/transcript-container.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
- package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
- package/dist/types/modes/index.d.ts +5 -4
- package/dist/types/modes/interactive-mode.d.ts +16 -6
- package/dist/types/modes/setup-version.d.ts +11 -0
- package/dist/types/modes/setup-wizard/index.d.ts +2 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +19 -6
- package/dist/types/modes/utils/copy-targets.d.ts +21 -1
- package/dist/types/plan-mode/approved-plan.d.ts +27 -8
- package/dist/types/plan-mode/plan-protection.d.ts +4 -4
- package/dist/types/sdk.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +21 -0
- package/dist/types/session/messages.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +4 -6
- package/dist/types/task/executor.d.ts +14 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/render.d.ts +3 -2
- package/dist/types/telemetry-export.d.ts +1 -1
- package/dist/types/tools/archive-reader.d.ts +5 -0
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +1 -8
- package/dist/types/tools/fetch.d.ts +15 -7
- package/dist/types/tools/find.d.ts +8 -4
- package/dist/types/tools/grouped-file-output.d.ts +95 -12
- package/dist/types/tools/memory-render.d.ts +4 -1
- package/dist/types/tools/plan-mode-guard.d.ts +8 -9
- package/dist/types/tools/render-utils.d.ts +13 -9
- package/dist/types/tools/renderers.d.ts +16 -2
- package/dist/types/tools/search.d.ts +5 -1
- package/dist/types/tools/sqlite-reader.d.ts +1 -0
- package/dist/types/tools/todo.d.ts +3 -2
- package/dist/types/tools/write.d.ts +5 -0
- package/dist/types/tui/output-block.d.ts +16 -4
- package/dist/types/tui/status-line.d.ts +3 -0
- package/dist/types/utils/enhanced-paste.d.ts +20 -0
- package/dist/types/web/scrapers/github.d.ts +22 -0
- package/dist/types/web/search/providers/kimi.d.ts +1 -1
- package/dist/types/web/search/providers/perplexity.d.ts +8 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +9 -9
- package/scripts/dev-launch +42 -0
- package/scripts/dev-launch-preload.ts +19 -0
- package/src/auto-thinking/classifier.ts +5 -1
- package/src/cli/args.ts +2 -2
- package/src/cli/dry-balance-cli.ts +52 -17
- package/src/cli/gallery-cli.ts +226 -0
- package/src/cli/gallery-fixtures/agentic.ts +292 -0
- package/src/cli/gallery-fixtures/codeintel.ts +188 -0
- package/src/cli/gallery-fixtures/edit.ts +194 -0
- package/src/cli/gallery-fixtures/fs.ts +153 -0
- package/src/cli/gallery-fixtures/index.ts +40 -0
- package/src/cli/gallery-fixtures/interaction.ts +49 -0
- package/src/cli/gallery-fixtures/memory.ts +81 -0
- package/src/cli/gallery-fixtures/misc.ts +250 -0
- package/src/cli/gallery-fixtures/search.ts +213 -0
- package/src/cli/gallery-fixtures/shell.ts +167 -0
- package/src/cli/gallery-fixtures/types.ts +41 -0
- package/src/cli/gallery-fixtures/web.ts +158 -0
- package/src/cli/gallery-screenshot.ts +279 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/gallery.ts +52 -0
- package/src/commands/launch.ts +1 -1
- package/src/commit/analysis/conventional.ts +2 -2
- package/src/commit/analysis/summary.ts +2 -2
- package/src/commit/changelog/generate.ts +2 -2
- package/src/commit/changelog/index.ts +2 -2
- package/src/commit/map-reduce/index.ts +3 -3
- package/src/commit/map-reduce/map-phase.ts +2 -2
- package/src/commit/map-reduce/reduce-phase.ts +2 -2
- package/src/commit/model-selection.ts +33 -9
- package/src/commit/pipeline.ts +4 -4
- package/src/config/api-key-resolver.ts +58 -0
- package/src/config/keybindings.ts +15 -6
- package/src/config/model-equivalence.ts +35 -12
- package/src/config/model-id-affixes.ts +39 -22
- package/src/config/model-registry.ts +41 -18
- package/src/config/settings-schema.ts +28 -5
- package/src/config/settings.ts +31 -2
- package/src/dap/client.ts +14 -16
- package/src/dap/config.ts +41 -2
- package/src/dap/defaults.json +1 -0
- package/src/dap/session.ts +1 -0
- package/src/dap/types.ts +10 -0
- package/src/debug/index.ts +40 -54
- package/src/edit/renderer.ts +111 -119
- package/src/eval/__tests__/agent-bridge.test.ts +75 -32
- package/src/eval/__tests__/llm-bridge.test.ts +90 -31
- package/src/eval/agent-bridge.ts +34 -7
- package/src/eval/llm-bridge.ts +8 -3
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/plugins/doctor.ts +0 -1
- package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
- package/src/goals/tools/goal-tool.ts +37 -27
- package/src/internal-urls/docs-index.generated.ts +10 -10
- package/src/lsp/client.ts +104 -55
- package/src/lsp/types.ts +10 -0
- package/src/lsp/utils.ts +3 -2
- package/src/main.ts +53 -56
- package/src/memories/index.ts +12 -5
- package/src/memory-backend/index.ts +13 -1
- package/src/memory-backend/resolve.ts +3 -5
- package/src/memory-backend/types.ts +1 -1
- package/src/mnemopi/backend.ts +5 -1
- package/src/modes/acp/acp-agent.ts +33 -26
- package/src/modes/components/assistant-message.ts +2 -9
- package/src/modes/components/chat-block.ts +111 -0
- package/src/modes/components/copy-selector.ts +1 -44
- package/src/modes/components/custom-editor.ts +33 -1
- package/src/modes/components/custom-message.ts +1 -3
- package/src/modes/components/execution-shared.ts +1 -2
- package/src/modes/components/hook-message.ts +1 -3
- package/src/modes/components/overlay-box.ts +108 -0
- package/src/modes/components/plan-review-overlay.ts +799 -0
- package/src/modes/components/plan-toc.ts +138 -0
- package/src/modes/components/read-tool-group.ts +20 -4
- package/src/modes/components/skill-message.ts +0 -1
- package/src/modes/components/status-line.ts +3 -5
- package/src/modes/components/tips.txt +1 -0
- package/src/modes/components/todo-reminder.ts +0 -2
- package/src/modes/components/tool-execution.ts +115 -90
- package/src/modes/components/transcript-container.ts +84 -24
- package/src/modes/components/user-message.ts +1 -2
- package/src/modes/controllers/command-controller-shared.ts +7 -6
- package/src/modes/controllers/command-controller.ts +70 -57
- package/src/modes/controllers/event-controller.ts +41 -40
- package/src/modes/controllers/extension-ui-controller.ts +10 -73
- package/src/modes/controllers/input-controller.ts +135 -122
- package/src/modes/controllers/mcp-command-controller.ts +69 -60
- package/src/modes/controllers/selector-controller.ts +25 -27
- package/src/modes/controllers/streaming-reveal.ts +212 -0
- package/src/modes/controllers/tan-command-controller.ts +173 -0
- package/src/modes/index.ts +5 -4
- package/src/modes/interactive-mode.ts +171 -82
- package/src/modes/setup-version.ts +11 -0
- package/src/modes/setup-wizard/index.ts +3 -2
- package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme-schema.json +1 -1
- package/src/modes/theme/theme.ts +8 -4
- package/src/modes/types.ts +19 -8
- package/src/modes/utils/context-usage.ts +10 -6
- package/src/modes/utils/copy-targets.ts +133 -27
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +44 -46
- package/src/plan-mode/approved-plan.ts +66 -43
- package/src/plan-mode/plan-protection.ts +4 -4
- package/src/prompts/system/background-tan-dispatch.md +8 -0
- package/src/prompts/system/plan-mode-active.md +67 -58
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/sdk.ts +32 -60
- package/src/session/agent-session.ts +89 -13
- package/src/session/messages.ts +26 -0
- package/src/session/session-manager.ts +13 -5
- package/src/slash-commands/builtin-registry.ts +37 -10
- package/src/slash-commands/helpers/usage-report.ts +2 -0
- package/src/slash-commands/types.ts +4 -6
- package/src/task/executor.ts +25 -4
- package/src/task/index.ts +4 -0
- package/src/task/render.ts +212 -148
- package/src/telemetry-export.ts +25 -7
- package/src/tools/archive-reader.ts +64 -0
- package/src/tools/ask.ts +119 -164
- package/src/tools/ast-edit.ts +98 -71
- package/src/tools/ast-grep.ts +37 -43
- package/src/tools/bash.ts +50 -6
- package/src/tools/debug.ts +20 -8
- package/src/tools/eval-backends.ts +6 -17
- package/src/tools/eval-render.ts +21 -18
- package/src/tools/eval.ts +5 -4
- package/src/tools/fetch.ts +391 -91
- package/src/tools/find.ts +44 -30
- package/src/tools/gh-renderer.ts +81 -42
- package/src/tools/grouped-file-output.ts +272 -48
- package/src/tools/image-gen.ts +150 -103
- package/src/tools/inspect-image-renderer.ts +63 -41
- package/src/tools/inspect-image.ts +8 -1
- package/src/tools/job.ts +3 -4
- package/src/tools/memory-render.ts +4 -1
- package/src/tools/plan-mode-guard.ts +21 -39
- package/src/tools/read.ts +23 -16
- package/src/tools/render-utils.ts +38 -40
- package/src/tools/renderers.ts +16 -1
- package/src/tools/report-tool-issue.ts +1 -1
- package/src/tools/resolve.ts +14 -0
- package/src/tools/search-tool-bm25.ts +36 -23
- package/src/tools/search.ts +189 -95
- package/src/tools/sqlite-reader.ts +9 -12
- package/src/tools/todo.ts +138 -59
- package/src/tools/write.ts +100 -60
- package/src/tui/output-block.ts +60 -13
- package/src/tui/status-line.ts +5 -1
- package/src/utils/commit-message-generator.ts +9 -1
- package/src/utils/enhanced-paste.ts +202 -0
- package/src/utils/title-generator.ts +2 -1
- package/src/web/scrapers/github.ts +255 -3
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/providers/anthropic.ts +25 -19
- package/src/web/search/providers/exa.ts +11 -3
- package/src/web/search/providers/kimi.ts +28 -17
- package/src/web/search/providers/parallel.ts +35 -24
- package/src/web/search/providers/perplexity.ts +199 -51
- package/src/web/search/providers/synthetic.ts +8 -6
- package/src/web/search/providers/tavily.ts +9 -8
- package/src/web/search/providers/zai.ts +8 -6
- package/src/web/search/render.ts +39 -54
- package/src/web/search/types.ts +5 -1
- package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
- package/src/eval/__tests__/shared-executors.test.ts +0 -609
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fullscreen plan-review overlay. The overlay owns its entire content: the plan
|
|
3
|
+
* is split into sections (preamble + one per heading), each rendered through its
|
|
4
|
+
* own {@link Markdown} and windowed by a {@link ScrollView}, while the approval
|
|
5
|
+
* options (plus the optional model-tier slider) sit beneath inside the same
|
|
6
|
+
* outlined box — one self-contained surface in the spirit of the `/copy` picker.
|
|
7
|
+
*
|
|
8
|
+
* When the terminal is wide enough and the plan has ≥2 headings, a Contents
|
|
9
|
+
* sidebar appears: it tracks the scrolled section with an accent "glow", and —
|
|
10
|
+
* when focused — lets the operator jump between sections, delete a section
|
|
11
|
+
* (with undo), and annotate sections with feedback that feeds the Refine loop.
|
|
12
|
+
*
|
|
13
|
+
* Focus regions (`toc`/`body`/`actions`) cycle with Tab/Shift+Tab; arrows move
|
|
14
|
+
* within the focused region and step left into the sidebar. The default focus is
|
|
15
|
+
* `actions`, so the muscle memory of the old single-target overlay carries over:
|
|
16
|
+
* ↑/↓ select options, Enter confirms, ←/→ drives the slider when there is no
|
|
17
|
+
* sidebar, g/G + PgUp/PgDn scroll, and the external-editor key opens the plan.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
type Component,
|
|
21
|
+
Ellipsis,
|
|
22
|
+
Input,
|
|
23
|
+
Markdown,
|
|
24
|
+
type MarkdownTheme,
|
|
25
|
+
matchesKey,
|
|
26
|
+
ScrollView,
|
|
27
|
+
truncateToWidth,
|
|
28
|
+
visibleWidth,
|
|
29
|
+
} from "@oh-my-pi/pi-tui";
|
|
30
|
+
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
31
|
+
import {
|
|
32
|
+
matchesAppExternalEditor,
|
|
33
|
+
matchesSelectCancel,
|
|
34
|
+
matchesSelectDown,
|
|
35
|
+
matchesSelectUp,
|
|
36
|
+
} from "../utils/keybinding-matchers";
|
|
37
|
+
import type { HookSelectorSlider } from "./hook-selector";
|
|
38
|
+
import {
|
|
39
|
+
bottomBorder,
|
|
40
|
+
divider,
|
|
41
|
+
dividerSplit,
|
|
42
|
+
fit,
|
|
43
|
+
row,
|
|
44
|
+
splitBodyWidth,
|
|
45
|
+
splitRow,
|
|
46
|
+
topBorder,
|
|
47
|
+
topBorderSplit,
|
|
48
|
+
} from "./overlay-box";
|
|
49
|
+
import { joinPlanSections, parsePlanSections, sectionDeletionSpan } from "./plan-toc";
|
|
50
|
+
import { renderSegmentTrack } from "./segment-track";
|
|
51
|
+
|
|
52
|
+
/** Title shown in the overlay's top border. */
|
|
53
|
+
const OVERLAY_TITLE = "Plan Review";
|
|
54
|
+
/** Minimum plan-body rows kept visible even on short terminals. */
|
|
55
|
+
const MIN_BODY_ROWS = 3;
|
|
56
|
+
/** Sidebar gates: enough headings, a wide terminal, and a usable body column. */
|
|
57
|
+
const SIDEBAR_MIN_HEADINGS = 2;
|
|
58
|
+
const SIDEBAR_MIN_TOTAL_WIDTH = 64;
|
|
59
|
+
const SIDEBAR_MIN_BODY_WIDTH = 40;
|
|
60
|
+
|
|
61
|
+
type Focus = "toc" | "body" | "actions";
|
|
62
|
+
|
|
63
|
+
interface OverlaySection {
|
|
64
|
+
level: number;
|
|
65
|
+
title: string;
|
|
66
|
+
raw: string;
|
|
67
|
+
md: Markdown;
|
|
68
|
+
annotations: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Undo snapshot: joined plan text, annotations aligned by section, and the
|
|
72
|
+
* accumulated deleted-section feedback at the time of the snapshot. */
|
|
73
|
+
interface UndoEntry {
|
|
74
|
+
text: string;
|
|
75
|
+
annotations: string[][];
|
|
76
|
+
deleted: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PlanReviewOverlayCallbacks {
|
|
80
|
+
/** Invoked with the chosen option label (never a disabled one). */
|
|
81
|
+
onPick: (label: string) => void;
|
|
82
|
+
/** Invoked on Esc / cancel. */
|
|
83
|
+
onCancel: () => void;
|
|
84
|
+
/** Invoked when the external-editor key is pressed (overlay stays open). */
|
|
85
|
+
onExternalEditor?: () => void;
|
|
86
|
+
/** Invoked with the new full plan text after an in-overlay delete/undo. */
|
|
87
|
+
onPlanEdited?: (content: string) => void;
|
|
88
|
+
/** Invoked with the Refine feedback markdown whenever annotations change. */
|
|
89
|
+
onFeedbackChange?: (feedback: string) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PlanReviewOverlayOptions {
|
|
93
|
+
/** Prompt rendered above the options (e.g. "Plan mode - next step"). */
|
|
94
|
+
promptTitle?: string;
|
|
95
|
+
options: string[];
|
|
96
|
+
/** Indices into `options` that render dimmed and cannot be selected. */
|
|
97
|
+
disabledIndices?: number[];
|
|
98
|
+
/** Trailing footer hint (cancel hint); the overlay prepends dynamic help. */
|
|
99
|
+
helpText?: string;
|
|
100
|
+
/** Initially highlighted option index. */
|
|
101
|
+
initialIndex?: number;
|
|
102
|
+
/** Optional model-tier slider rendered between the plan body and options. */
|
|
103
|
+
slider?: HookSelectorSlider;
|
|
104
|
+
/** Display label for the external-editor key, surfaced in the footer help. */
|
|
105
|
+
externalEditorLabel?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default trailing footer hint when the caller supplies none. */
|
|
109
|
+
const DEFAULT_HELP_SUFFIX = "esc cancel";
|
|
110
|
+
|
|
111
|
+
export class PlanReviewOverlay implements Component {
|
|
112
|
+
#mdTheme: MarkdownTheme;
|
|
113
|
+
#scrollView: ScrollView;
|
|
114
|
+
#sections: OverlaySection[] = [];
|
|
115
|
+
#toc: number[] = [];
|
|
116
|
+
/** Shallowest level among ToC entries, used to flatten indentation. */
|
|
117
|
+
#tocBaseLevel = 1;
|
|
118
|
+
#sectionOffsets: number[] = [];
|
|
119
|
+
#undo: UndoEntry[] = [];
|
|
120
|
+
/** Titles of sections deleted in the overlay, surfaced as Refine feedback. */
|
|
121
|
+
#deleted: string[] = [];
|
|
122
|
+
|
|
123
|
+
#options: string[];
|
|
124
|
+
#disabled: Set<number>;
|
|
125
|
+
#helpSuffix: string;
|
|
126
|
+
#externalEditorLabel: string | undefined;
|
|
127
|
+
#promptTitle: string | undefined;
|
|
128
|
+
#selectedIndex: number;
|
|
129
|
+
#slider: HookSelectorSlider | undefined;
|
|
130
|
+
#sliderIndex: number;
|
|
131
|
+
|
|
132
|
+
#focus: Focus = "actions";
|
|
133
|
+
#tocCursor = 0;
|
|
134
|
+
#sidebarShown = false;
|
|
135
|
+
#pendingScrollToToc = false;
|
|
136
|
+
|
|
137
|
+
// Click hit-testing, rebuilt every render. Keys are 0-based rendered-line
|
|
138
|
+
// indices (== screen rows, since the fullscreen overlay paints from row 0).
|
|
139
|
+
#optionClickRows = new Map<number, number>();
|
|
140
|
+
#tocClickRows = new Map<number, number>();
|
|
141
|
+
#bodyClickRows = new Set<number>();
|
|
142
|
+
/** 1-based column at/under which a region-row click targets the sidebar. */
|
|
143
|
+
#sidebarClickMaxCol = 0;
|
|
144
|
+
|
|
145
|
+
#annotating = false;
|
|
146
|
+
#input: Input;
|
|
147
|
+
|
|
148
|
+
constructor(
|
|
149
|
+
planContent: string,
|
|
150
|
+
options: PlanReviewOverlayOptions,
|
|
151
|
+
private readonly callbacks: PlanReviewOverlayCallbacks,
|
|
152
|
+
) {
|
|
153
|
+
this.#mdTheme = getMarkdownTheme();
|
|
154
|
+
this.#scrollView = new ScrollView([], {
|
|
155
|
+
height: MIN_BODY_ROWS,
|
|
156
|
+
scrollbar: "auto",
|
|
157
|
+
ellipsis: Ellipsis.Omit,
|
|
158
|
+
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
|
|
159
|
+
});
|
|
160
|
+
this.#options = options.options;
|
|
161
|
+
this.#disabled = new Set(
|
|
162
|
+
(options.disabledIndices ?? []).filter(i => Number.isInteger(i) && i >= 0 && i < this.#options.length),
|
|
163
|
+
);
|
|
164
|
+
this.#helpSuffix = options.helpText ?? DEFAULT_HELP_SUFFIX;
|
|
165
|
+
this.#externalEditorLabel = options.externalEditorLabel;
|
|
166
|
+
this.#promptTitle = options.promptTitle;
|
|
167
|
+
this.#selectedIndex = this.#coerceIndex(options.initialIndex ?? 0);
|
|
168
|
+
if (options.slider && options.slider.segments.length > 0) {
|
|
169
|
+
this.#slider = options.slider;
|
|
170
|
+
this.#sliderIndex = Math.max(0, Math.min(options.slider.index, options.slider.segments.length - 1));
|
|
171
|
+
} else {
|
|
172
|
+
this.#sliderIndex = 0;
|
|
173
|
+
}
|
|
174
|
+
this.#input = new Input();
|
|
175
|
+
this.#input.setUseTerminalCursor(false);
|
|
176
|
+
this.#input.onSubmit = value => this.#submitAnnotation(value);
|
|
177
|
+
this.#input.onEscape = () => this.#exitAnnotate();
|
|
178
|
+
this.#setSections(planContent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
invalidate(): void {
|
|
182
|
+
for (const section of this.#sections) section.md.invalidate();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Swap the displayed plan (e.g. after an external-editor round-trip) and
|
|
186
|
+
* reset scroll/focus so the operator starts at the top. Does not emit
|
|
187
|
+
* `onPlanEdited` (the editor round-trip already persisted the file). */
|
|
188
|
+
setPlanContent(planContent: string): void {
|
|
189
|
+
this.#setSections(planContent);
|
|
190
|
+
this.#scrollView.scrollToTop();
|
|
191
|
+
this.#tocCursor = 0;
|
|
192
|
+
// A wholesale external-editor swap supersedes prior in-overlay deletions.
|
|
193
|
+
this.#deleted = [];
|
|
194
|
+
this.#undo = [];
|
|
195
|
+
this.#recomputeFeedback();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#setSections(planContent: string): void {
|
|
199
|
+
this.#sections = parsePlanSections(planContent).map(section => ({
|
|
200
|
+
level: section.level,
|
|
201
|
+
title: section.title,
|
|
202
|
+
raw: section.raw,
|
|
203
|
+
md: new Markdown(section.raw, 1, 0, this.#mdTheme),
|
|
204
|
+
annotations: [] as string[],
|
|
205
|
+
}));
|
|
206
|
+
this.#rebuildToc();
|
|
207
|
+
this.#tocCursor = Math.min(this.#tocCursor, Math.max(0, this.#toc.length - 1));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#rebuildToc(): void {
|
|
211
|
+
const headings: number[] = [];
|
|
212
|
+
for (let i = 0; i < this.#sections.length; i++) {
|
|
213
|
+
if (this.#sections[i]!.level >= 1) headings.push(i);
|
|
214
|
+
}
|
|
215
|
+
// Drop the plan's title from the ToC: a single shallowest heading at the
|
|
216
|
+
// top of the document is the plan name itself ("we know it's the plan"),
|
|
217
|
+
// so listing it adds noise. Plans with several top-level sections keep
|
|
218
|
+
// them all.
|
|
219
|
+
let minLevel = Number.POSITIVE_INFINITY;
|
|
220
|
+
for (const i of headings) minLevel = Math.min(minLevel, this.#sections[i]!.level);
|
|
221
|
+
const topLevel = headings.filter(i => this.#sections[i]!.level === minLevel);
|
|
222
|
+
const titleIndex = topLevel.length === 1 && headings[0] === topLevel[0] ? topLevel[0] : -1;
|
|
223
|
+
this.#toc = headings.filter(i => i !== titleIndex);
|
|
224
|
+
this.#tocBaseLevel = this.#toc.length > 0 ? Math.min(...this.#toc.map(i => this.#sections[i]!.level)) : 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Clamp `index` to range, then walk to the nearest enabled option so the
|
|
228
|
+
* cursor never rests on a disabled row. */
|
|
229
|
+
#coerceIndex(index: number): number {
|
|
230
|
+
const max = this.#options.length - 1;
|
|
231
|
+
if (max < 0) return -1;
|
|
232
|
+
const clamped = Math.max(0, Math.min(index, max));
|
|
233
|
+
if (!this.#disabled.has(clamped)) return clamped;
|
|
234
|
+
for (let i = clamped + 1; i <= max; i++) if (!this.#disabled.has(i)) return i;
|
|
235
|
+
for (let i = clamped - 1; i >= 0; i--) if (!this.#disabled.has(i)) return i;
|
|
236
|
+
return clamped;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** First enabled option index (or -1 when none), used to detect the "top". */
|
|
240
|
+
#firstEnabledIndex(): number {
|
|
241
|
+
for (let i = 0; i < this.#options.length; i++) if (!this.#disabled.has(i)) return i;
|
|
242
|
+
return -1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Move the option cursor by `delta`, skipping disabled rows, stopping at the
|
|
246
|
+
* list edge. */
|
|
247
|
+
#moveSelection(delta: number): void {
|
|
248
|
+
const max = this.#options.length - 1;
|
|
249
|
+
if (max < 0) return;
|
|
250
|
+
let index = this.#selectedIndex;
|
|
251
|
+
while (true) {
|
|
252
|
+
const next = Math.max(0, Math.min(index + delta, max));
|
|
253
|
+
if (next === index) return;
|
|
254
|
+
index = next;
|
|
255
|
+
if (!this.#disabled.has(index)) {
|
|
256
|
+
this.#selectedIndex = index;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Step the slider by `delta`, clamped to its edges (narrow-terminal mode). */
|
|
263
|
+
#moveSlider(delta: number): void {
|
|
264
|
+
const slider = this.#slider;
|
|
265
|
+
if (!slider) return;
|
|
266
|
+
const next = Math.max(0, Math.min(slider.segments.length - 1, this.#sliderIndex + delta));
|
|
267
|
+
if (next === this.#sliderIndex) return;
|
|
268
|
+
this.#sliderIndex = next;
|
|
269
|
+
slider.onChange?.(next);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#confirmSelection(): void {
|
|
273
|
+
const index = this.#selectedIndex;
|
|
274
|
+
if (index >= 0 && index < this.#options.length && !this.#disabled.has(index)) {
|
|
275
|
+
this.callbacks.onPick(this.#options[index]!);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
handleInput(keyData: string): void {
|
|
280
|
+
if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
|
|
281
|
+
if (this.#annotating) {
|
|
282
|
+
this.#input.handleInput(keyData);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (matchesSelectCancel(keyData)) {
|
|
286
|
+
this.callbacks.onCancel();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (this.callbacks.onExternalEditor && matchesAppExternalEditor(keyData)) {
|
|
290
|
+
this.callbacks.onExternalEditor();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (matchesKey(keyData, "tab") || keyData === "\t") {
|
|
294
|
+
this.#cycleRegion(1);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (matchesKey(keyData, "shift+tab") || keyData === "\x1b[Z") {
|
|
298
|
+
this.#cycleRegion(-1);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
switch (this.#focus) {
|
|
302
|
+
case "actions":
|
|
303
|
+
this.#handleActions(keyData);
|
|
304
|
+
return;
|
|
305
|
+
case "body":
|
|
306
|
+
this.#handleBody(keyData);
|
|
307
|
+
return;
|
|
308
|
+
case "toc":
|
|
309
|
+
this.#handleToc(keyData);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Hit-test an SGR mouse report (`\x1b[<b;x;yM/m`) against the click maps the
|
|
316
|
+
* last render recorded. Returns true when consumed. The fullscreen overlay
|
|
317
|
+
* paints from screen row 0, so a 1-based mouse row maps directly to the
|
|
318
|
+
* rendered-line index. Wheel scrolls the body; a left click on an option
|
|
319
|
+
* activates it (select + confirm), on a ToC row jumps to that section, and on
|
|
320
|
+
* the body column focuses the body.
|
|
321
|
+
*/
|
|
322
|
+
#handleMouse(data: string): boolean {
|
|
323
|
+
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
|
|
324
|
+
if (!match) return false;
|
|
325
|
+
const button = Number(match[1]);
|
|
326
|
+
const x = Number(match[2]);
|
|
327
|
+
const row = Number(match[3]) - 1;
|
|
328
|
+
if (button & 64) {
|
|
329
|
+
// Scroll wheel: low bit selects direction (64 up, 65 down).
|
|
330
|
+
this.#scrollView.scroll(button & 1 ? 3 : -3);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
if (match[4] !== "M") return true; // release
|
|
334
|
+
if (button & 32) return true; // motion/drag
|
|
335
|
+
if ((button & 3) !== 0) return true; // not the left button
|
|
336
|
+
const optionIndex = this.#optionClickRows.get(row);
|
|
337
|
+
if (optionIndex !== undefined) {
|
|
338
|
+
if (!this.#disabled.has(optionIndex)) {
|
|
339
|
+
this.#focus = "actions";
|
|
340
|
+
this.#selectedIndex = optionIndex;
|
|
341
|
+
this.#confirmSelection();
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
const tocPos = this.#tocClickRows.get(row);
|
|
346
|
+
if (tocPos !== undefined && x <= this.#sidebarClickMaxCol) {
|
|
347
|
+
this.#focus = "toc";
|
|
348
|
+
this.#tocCursor = tocPos;
|
|
349
|
+
this.#scrubBodyToToc();
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
if (this.#bodyClickRows.has(row)) {
|
|
353
|
+
this.#setFocus("body");
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#cycleRegion(direction: number): void {
|
|
359
|
+
// Sidebar is skipped from the cycle when it is not shown.
|
|
360
|
+
const regions: Focus[] = this.#sidebarShown ? ["toc", "body", "actions"] : ["body", "actions"];
|
|
361
|
+
const current = regions.indexOf(this.#focus);
|
|
362
|
+
const base = current < 0 ? regions.length - 1 : current;
|
|
363
|
+
this.#setFocus(regions[(base + direction + regions.length) % regions.length]!);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#setFocus(focus: Focus): void {
|
|
367
|
+
this.#focus = focus;
|
|
368
|
+
if (focus === "toc") this.#tocCursor = this.#deriveTocCursorFromScroll();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#handleActions(data: string): void {
|
|
372
|
+
// Left/right always drive the slider. The sidebar sits beside the body
|
|
373
|
+
// (above this row), not the slider, so stealing left for it would strand
|
|
374
|
+
// the operator unable to step the model tier back — reach the ToC via Tab.
|
|
375
|
+
const isLeft = matchesKey(data, "left") || (this.#slider !== undefined && data === "h");
|
|
376
|
+
const isRight = matchesKey(data, "right") || (this.#slider !== undefined && data === "l");
|
|
377
|
+
if (isLeft) {
|
|
378
|
+
this.#moveSlider(-1);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (isRight) {
|
|
382
|
+
this.#moveSlider(1);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (matchesSelectUp(data) || data === "k") {
|
|
386
|
+
if (this.#selectedIndex === this.#firstEnabledIndex()) this.#setFocus("body");
|
|
387
|
+
else this.#moveSelection(-1);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (matchesSelectDown(data) || data === "j") {
|
|
391
|
+
this.#moveSelection(1);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
395
|
+
this.#confirmSelection();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
this.#handleBodyScroll(data);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#handleBody(data: string): void {
|
|
402
|
+
if (matchesKey(data, "left") || data === "h") {
|
|
403
|
+
if (this.#sidebarShown) this.#setFocus("toc");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (
|
|
407
|
+
matchesKey(data, "right") ||
|
|
408
|
+
data === "l" ||
|
|
409
|
+
matchesKey(data, "enter") ||
|
|
410
|
+
matchesKey(data, "return") ||
|
|
411
|
+
data === "\n"
|
|
412
|
+
) {
|
|
413
|
+
this.#setFocus("actions");
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Vertical nav flows between regions at the edges: scrolling off the bottom
|
|
417
|
+
// drops into the actions ("next step"); scrolling off the top steps back up
|
|
418
|
+
// to the ToC.
|
|
419
|
+
if (matchesSelectUp(data) || data === "k") {
|
|
420
|
+
if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) this.#setFocus("toc");
|
|
421
|
+
else this.#scrollView.scroll(-1);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (matchesSelectDown(data) || data === "j") {
|
|
425
|
+
if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) this.#setFocus("actions");
|
|
426
|
+
else this.#scrollView.scroll(1);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
this.#handleBodyScroll(data);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Shared scroll dispatch for body + actions focus. Delegates standard keys
|
|
434
|
+
* (Arrows, Shift+Arrow fast-scroll, PgUp/PgDn, Home/End) to the ScrollView,
|
|
435
|
+
* then adds the vim g/G jumps. Plain Arrow/k/j are consumed by the callers
|
|
436
|
+
* before this runs, so here it only ever sees the paging/fast keys.
|
|
437
|
+
*/
|
|
438
|
+
#handleBodyScroll(data: string): void {
|
|
439
|
+
if (this.#scrollView.handleScrollKey(data)) return;
|
|
440
|
+
if (data === "g") this.#scrollView.scrollToTop();
|
|
441
|
+
else if (data === "G") this.#scrollView.scrollToBottom();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#handleToc(data: string): void {
|
|
445
|
+
if (matchesSelectUp(data) || data === "k") {
|
|
446
|
+
this.#moveTocCursor(-1);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (matchesSelectDown(data) || data === "j") {
|
|
450
|
+
// Past the last section, fall through to the actions ("next step").
|
|
451
|
+
if (this.#tocCursor >= this.#toc.length - 1) this.#setFocus("actions");
|
|
452
|
+
else this.#moveTocCursor(1);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (
|
|
456
|
+
matchesKey(data, "right") ||
|
|
457
|
+
data === "l" ||
|
|
458
|
+
matchesKey(data, "enter") ||
|
|
459
|
+
matchesKey(data, "return") ||
|
|
460
|
+
data === "\n"
|
|
461
|
+
) {
|
|
462
|
+
this.#setFocus("body");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (data === "d" || matchesKey(data, "delete")) {
|
|
466
|
+
this.#deleteSelectedSection();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (data === "a") {
|
|
470
|
+
this.#startAnnotate();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (data === "u") {
|
|
474
|
+
this.#undoLast();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#moveTocCursor(delta: number): void {
|
|
480
|
+
if (this.#toc.length === 0) return;
|
|
481
|
+
const next = Math.max(0, Math.min(this.#toc.length - 1, this.#tocCursor + delta));
|
|
482
|
+
if (next === this.#tocCursor) return;
|
|
483
|
+
this.#tocCursor = next;
|
|
484
|
+
this.#scrubBodyToToc();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Scroll the body so the selected ToC section's heading sits at the top. */
|
|
488
|
+
#scrubBodyToToc(): void {
|
|
489
|
+
const sectionIndex = this.#toc[this.#tocCursor];
|
|
490
|
+
if (sectionIndex === undefined) return;
|
|
491
|
+
const offset = this.#sectionOffsets[sectionIndex];
|
|
492
|
+
if (offset !== undefined) this.#scrollView.setScrollOffset(offset);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Greatest ToC position whose section starts at or above the scroll offset. */
|
|
496
|
+
#deriveTocCursorFromScroll(): number {
|
|
497
|
+
if (this.#toc.length === 0) return 0;
|
|
498
|
+
const scrollOffset = this.#scrollView.getScrollOffset();
|
|
499
|
+
let current = 0;
|
|
500
|
+
for (let i = 0; i < this.#sections.length; i++) {
|
|
501
|
+
if ((this.#sectionOffsets[i] ?? 0) <= scrollOffset) current = i;
|
|
502
|
+
else break;
|
|
503
|
+
}
|
|
504
|
+
let pos = 0;
|
|
505
|
+
for (let p = 0; p < this.#toc.length; p++) {
|
|
506
|
+
if (this.#toc[p]! <= current) pos = p;
|
|
507
|
+
else break;
|
|
508
|
+
}
|
|
509
|
+
return pos;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#pushUndo(): void {
|
|
513
|
+
this.#undo.push({
|
|
514
|
+
text: joinPlanSections(this.#sections),
|
|
515
|
+
annotations: this.#sections.map(section => [...section.annotations]),
|
|
516
|
+
deleted: [...this.#deleted],
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#deleteSelectedSection(): void {
|
|
521
|
+
const sectionIndex = this.#toc[this.#tocCursor];
|
|
522
|
+
if (sectionIndex === undefined) return;
|
|
523
|
+
const span = sectionDeletionSpan(this.#sections, sectionIndex);
|
|
524
|
+
if (span.length === 0) return;
|
|
525
|
+
this.#pushUndo();
|
|
526
|
+
// Record the removed headings so the Refine feedback can ask the model to
|
|
527
|
+
// drop them, then splice from the bottom up so earlier indices stay valid.
|
|
528
|
+
for (const i of span) {
|
|
529
|
+
const section = this.#sections[i]!;
|
|
530
|
+
if (section.level >= 1 && section.title) this.#deleted.push(section.title);
|
|
531
|
+
}
|
|
532
|
+
for (let i = span.length - 1; i >= 0; i--) this.#sections.splice(span[i]!, 1);
|
|
533
|
+
this.#rebuildToc();
|
|
534
|
+
this.#tocCursor = Math.min(this.#tocCursor, Math.max(0, this.#toc.length - 1));
|
|
535
|
+
this.#pendingScrollToToc = true;
|
|
536
|
+
this.callbacks.onPlanEdited?.(joinPlanSections(this.#sections));
|
|
537
|
+
this.#recomputeFeedback();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#undoLast(): void {
|
|
541
|
+
const entry = this.#undo.pop();
|
|
542
|
+
if (!entry) return;
|
|
543
|
+
this.#setSections(entry.text);
|
|
544
|
+
for (let i = 0; i < this.#sections.length; i++) {
|
|
545
|
+
this.#sections[i]!.annotations = entry.annotations[i] ? [...entry.annotations[i]!] : [];
|
|
546
|
+
}
|
|
547
|
+
this.#deleted = [...entry.deleted];
|
|
548
|
+
this.#tocCursor = Math.min(this.#tocCursor, Math.max(0, this.#toc.length - 1));
|
|
549
|
+
this.#pendingScrollToToc = true;
|
|
550
|
+
this.callbacks.onPlanEdited?.(joinPlanSections(this.#sections));
|
|
551
|
+
this.#recomputeFeedback();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#startAnnotate(): void {
|
|
555
|
+
if (this.#toc[this.#tocCursor] === undefined) return;
|
|
556
|
+
this.#annotating = true;
|
|
557
|
+
this.#input.setValue("");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#submitAnnotation(value: string): void {
|
|
561
|
+
this.#annotating = false;
|
|
562
|
+
const note = value.trim();
|
|
563
|
+
const sectionIndex = this.#toc[this.#tocCursor];
|
|
564
|
+
if (note && sectionIndex !== undefined) {
|
|
565
|
+
this.#pushUndo();
|
|
566
|
+
this.#sections[sectionIndex]!.annotations.push(note);
|
|
567
|
+
this.#recomputeFeedback();
|
|
568
|
+
}
|
|
569
|
+
this.#input.setValue("");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
#exitAnnotate(): void {
|
|
573
|
+
this.#annotating = false;
|
|
574
|
+
this.#input.setValue("");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#recomputeFeedback(): void {
|
|
578
|
+
const annotated = this.#sections.filter(section => section.level >= 1 && section.annotations.length > 0);
|
|
579
|
+
if (annotated.length === 0 && this.#deleted.length === 0) {
|
|
580
|
+
this.callbacks.onFeedbackChange?.("");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
let feedback = "Refinement feedback on the plan:\n";
|
|
584
|
+
if (this.#deleted.length > 0) {
|
|
585
|
+
feedback += "\nRemove these sections:\n";
|
|
586
|
+
for (const title of this.#deleted) feedback += `- ${title}\n`;
|
|
587
|
+
}
|
|
588
|
+
for (const section of annotated) {
|
|
589
|
+
feedback += `\n## ${section.title}\n`;
|
|
590
|
+
for (const note of section.annotations) feedback += `- ${note}\n`;
|
|
591
|
+
}
|
|
592
|
+
this.callbacks.onFeedbackChange?.(feedback);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#renderSliderLines(): string[] {
|
|
596
|
+
const slider = this.#slider;
|
|
597
|
+
if (!slider) return [];
|
|
598
|
+
const active = this.#sliderIndex;
|
|
599
|
+
const track = renderSegmentTrack(slider.segments, active);
|
|
600
|
+
const leftArrow = theme.fg(active > 0 ? "accent" : "dim", "◂");
|
|
601
|
+
const rightArrow = theme.fg(active < slider.segments.length - 1 ? "accent" : "dim", "▸");
|
|
602
|
+
const caption = slider.caption ? `${theme.fg("dim", slider.caption)} ` : "";
|
|
603
|
+
const trackLine = `${caption}${leftArrow} ${track} ${rightArrow}`;
|
|
604
|
+
const detail = slider.segments[active]?.detail;
|
|
605
|
+
if (!detail) return [trackLine];
|
|
606
|
+
return [trackLine, ` ${theme.fg("dim", "↳")} ${theme.fg("muted", detail)}`];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#renderOptionLines(): string[] {
|
|
610
|
+
const active = this.#focus === "actions";
|
|
611
|
+
return this.#options.map((label, i) => {
|
|
612
|
+
const selected = i === this.#selectedIndex;
|
|
613
|
+
const isDisabled = this.#disabled.has(i);
|
|
614
|
+
// The cursor marks the selected option; it dims when actions are not the
|
|
615
|
+
// focused region so the active region's highlight stays unambiguous.
|
|
616
|
+
const cursor = selected ? theme.fg(active ? "accent" : "dim", `${theme.nav.cursor} `) : " ";
|
|
617
|
+
const text = isDisabled
|
|
618
|
+
? theme.fg("dim", label)
|
|
619
|
+
: selected && active
|
|
620
|
+
? theme.bold(theme.fg("accent", label))
|
|
621
|
+
: theme.fg("text", label);
|
|
622
|
+
return cursor + text;
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#buildHelp(): string {
|
|
627
|
+
const sep = " · ";
|
|
628
|
+
const parts: string[] = [];
|
|
629
|
+
switch (this.#focus) {
|
|
630
|
+
case "actions":
|
|
631
|
+
parts.push("↑↓ select", "⏎ confirm");
|
|
632
|
+
if (this.#slider) parts.push("◂▸ model");
|
|
633
|
+
break;
|
|
634
|
+
case "toc":
|
|
635
|
+
parts.push("↑↓ section", "⏎ open", "a annotate", "d delete", "u undo");
|
|
636
|
+
break;
|
|
637
|
+
case "body":
|
|
638
|
+
parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends");
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
parts.push("tab regions");
|
|
642
|
+
if (this.#externalEditorLabel && this.#focus !== "toc") parts.push(`${this.#externalEditorLabel} editor`);
|
|
643
|
+
parts.push(this.#helpSuffix);
|
|
644
|
+
return parts.join(sep);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Build the concatenated body lines and record each section's start row. */
|
|
648
|
+
#buildBody(bodyContentWidth: number): string[] {
|
|
649
|
+
const lines: string[] = [];
|
|
650
|
+
const offsets: number[] = new Array(this.#sections.length);
|
|
651
|
+
for (let i = 0; i < this.#sections.length; i++) {
|
|
652
|
+
const section = this.#sections[i]!;
|
|
653
|
+
offsets[i] = lines.length;
|
|
654
|
+
const rendered = section.md.render(bodyContentWidth);
|
|
655
|
+
if (section.level >= 1 && section.annotations.length > 0 && rendered.length > 0) {
|
|
656
|
+
lines.push(rendered[0]!);
|
|
657
|
+
for (const note of section.annotations) {
|
|
658
|
+
lines.push(`${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}${theme.fg("accent", note)}`);
|
|
659
|
+
}
|
|
660
|
+
for (let k = 1; k < rendered.length; k++) lines.push(rendered[k]!);
|
|
661
|
+
} else {
|
|
662
|
+
for (const line of rendered) lines.push(line);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
this.#sectionOffsets = offsets;
|
|
666
|
+
return lines;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
#sidebarWidthFor(width: number): number {
|
|
670
|
+
return Math.max(18, Math.min(30, Math.round(width * 0.24)));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
#sidebarVisible(width: number): boolean {
|
|
674
|
+
if (this.#toc.length < SIDEBAR_MIN_HEADINGS) return false;
|
|
675
|
+
if (width < SIDEBAR_MIN_TOTAL_WIDTH) return false;
|
|
676
|
+
return splitBodyWidth(width, this.#sidebarWidthFor(width)) >= SIDEBAR_MIN_BODY_WIDTH;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Sidebar lines plus, per row, the ToC position shown there (for clicks). */
|
|
680
|
+
#renderSidebarLines(
|
|
681
|
+
regionRows: number,
|
|
682
|
+
sidebarWidth: number,
|
|
683
|
+
): { lines: string[]; posForRow: (number | undefined)[] } {
|
|
684
|
+
// No "Contents" label and no plan-title entry: the box title already says
|
|
685
|
+
// "Plan Review", so the sidebar is just the bare list of sections, VS
|
|
686
|
+
// Code-style. Window the entries around the cursor.
|
|
687
|
+
const lines: string[] = [];
|
|
688
|
+
const posForRow: (number | undefined)[] = [];
|
|
689
|
+
const slots = Math.max(0, regionRows);
|
|
690
|
+
const total = this.#toc.length;
|
|
691
|
+
let start = 0;
|
|
692
|
+
if (total > slots) {
|
|
693
|
+
start = Math.max(0, Math.min(this.#tocCursor - Math.floor(slots / 2), total - slots));
|
|
694
|
+
}
|
|
695
|
+
for (let r = 0; r < slots; r++) {
|
|
696
|
+
const p = start + r;
|
|
697
|
+
lines.push(p < total ? this.#renderTocEntry(p, sidebarWidth) : "");
|
|
698
|
+
posForRow.push(p < total ? p : undefined);
|
|
699
|
+
}
|
|
700
|
+
return { lines, posForRow };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#renderTocEntry(p: number, width: number): string {
|
|
704
|
+
const section = this.#sections[this.#toc[p]!]!;
|
|
705
|
+
const highlighted = p === this.#tocCursor;
|
|
706
|
+
const selected = highlighted && this.#focus === "toc";
|
|
707
|
+
const glow = highlighted && this.#focus !== "toc";
|
|
708
|
+
// Compact, VS Code-like rows: a single-column gutter, one space of indent
|
|
709
|
+
// per nesting level, then the title and an annotation marker.
|
|
710
|
+
const indent = " ".repeat(Math.max(0, section.level - this.#tocBaseLevel));
|
|
711
|
+
const ann = section.annotations.length > 0 ? " ✎" : "";
|
|
712
|
+
const avail = Math.max(0, width - 1 - indent.length - visibleWidth(ann));
|
|
713
|
+
const title = truncateToWidth(section.title || "(untitled)", avail, Ellipsis.Unicode);
|
|
714
|
+
const body = indent + title + ann;
|
|
715
|
+
// Single-column gutter glyph: a cursor `›` on the focused selection, an
|
|
716
|
+
// accent bar `▎` on the current scrolled section, otherwise blank. The
|
|
717
|
+
// glyph keeps the cursor legible even where the selection background is
|
|
718
|
+
// subtle; the focused row also gets the full-row highlight.
|
|
719
|
+
const gutter = selected ? "›" : glow ? "▎" : " ";
|
|
720
|
+
const line = gutter + body;
|
|
721
|
+
if (selected) return theme.bg("selectedBg", theme.bold(fit(line, width)));
|
|
722
|
+
if (glow) return theme.fg("accent", line);
|
|
723
|
+
return theme.fg("muted", line);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#renderFooterLines(innerWidth: number): string[] {
|
|
727
|
+
if (this.#annotating) {
|
|
728
|
+
const section = this.#sections[this.#toc[this.#tocCursor]!];
|
|
729
|
+
const title = section?.title ?? "";
|
|
730
|
+
const caption = `${theme.fg("dim", "Annotate")} ${theme.fg("accent", `‹${title}›`)}`;
|
|
731
|
+
return [caption, this.#input.render(innerWidth)[0] ?? ""];
|
|
732
|
+
}
|
|
733
|
+
return [theme.fg("dim", this.#buildHelp())];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
render(width: number): string[] {
|
|
737
|
+
const termHeight = process.stdout.rows || 40;
|
|
738
|
+
const sidebarShown = this.#sidebarVisible(width);
|
|
739
|
+
this.#sidebarShown = sidebarShown;
|
|
740
|
+
const sidebarWidth = sidebarShown ? this.#sidebarWidthFor(width) : 0;
|
|
741
|
+
const innerWidth = Math.max(1, width - 4);
|
|
742
|
+
const bodyContentWidth = sidebarShown ? splitBodyWidth(width, sidebarWidth) : innerWidth;
|
|
743
|
+
|
|
744
|
+
const sliderLines = this.#renderSliderLines();
|
|
745
|
+
const optionLines = this.#renderOptionLines();
|
|
746
|
+
const promptLines = this.#promptTitle ? [theme.bold(theme.fg("accent", this.#promptTitle))] : [];
|
|
747
|
+
const footerLines = this.#renderFooterLines(innerWidth);
|
|
748
|
+
|
|
749
|
+
// Chrome rows: top border, two dividers, bottom border, plus the
|
|
750
|
+
// prompt/slider/option/footer rows between them.
|
|
751
|
+
const chrome = 4 + promptLines.length + sliderLines.length + optionLines.length + footerLines.length;
|
|
752
|
+
const regionRows = Math.max(MIN_BODY_ROWS, termHeight - chrome);
|
|
753
|
+
|
|
754
|
+
const bodyLines = this.#buildBody(bodyContentWidth);
|
|
755
|
+
this.#scrollView.setLines(bodyLines);
|
|
756
|
+
this.#scrollView.setHeight(regionRows);
|
|
757
|
+
if (this.#pendingScrollToToc) {
|
|
758
|
+
this.#pendingScrollToToc = false;
|
|
759
|
+
this.#scrubBodyToToc();
|
|
760
|
+
}
|
|
761
|
+
if (this.#focus !== "toc") this.#tocCursor = this.#deriveTocCursorFromScroll();
|
|
762
|
+
const body = this.#scrollView.render(bodyContentWidth);
|
|
763
|
+
|
|
764
|
+
this.#optionClickRows.clear();
|
|
765
|
+
this.#tocClickRows.clear();
|
|
766
|
+
this.#bodyClickRows.clear();
|
|
767
|
+
this.#sidebarClickMaxCol = sidebarShown ? sidebarWidth + 3 : 0;
|
|
768
|
+
|
|
769
|
+
const out: string[] = [];
|
|
770
|
+
if (sidebarShown) {
|
|
771
|
+
const { lines: sidebar, posForRow } = this.#renderSidebarLines(regionRows, sidebarWidth);
|
|
772
|
+
out.push(topBorderSplit(width, OVERLAY_TITLE, sidebarWidth));
|
|
773
|
+
for (let i = 0; i < regionRows; i++) {
|
|
774
|
+
const pos = posForRow[i];
|
|
775
|
+
if (pos !== undefined) this.#tocClickRows.set(out.length, pos);
|
|
776
|
+
this.#bodyClickRows.add(out.length);
|
|
777
|
+
out.push(splitRow(sidebar[i] ?? "", body[i] ?? "", width, sidebarWidth));
|
|
778
|
+
}
|
|
779
|
+
out.push(dividerSplit(width, sidebarWidth));
|
|
780
|
+
} else {
|
|
781
|
+
out.push(topBorder(width, OVERLAY_TITLE));
|
|
782
|
+
for (const line of body) {
|
|
783
|
+
this.#bodyClickRows.add(out.length);
|
|
784
|
+
out.push(row(line, width));
|
|
785
|
+
}
|
|
786
|
+
out.push(divider(width));
|
|
787
|
+
}
|
|
788
|
+
for (const line of promptLines) out.push(row(line, width));
|
|
789
|
+
for (const line of sliderLines) out.push(row(line, width));
|
|
790
|
+
for (let i = 0; i < optionLines.length; i++) {
|
|
791
|
+
this.#optionClickRows.set(out.length, i);
|
|
792
|
+
out.push(row(optionLines[i]!, width));
|
|
793
|
+
}
|
|
794
|
+
out.push(divider(width));
|
|
795
|
+
for (const line of footerLines) out.push(row(line, width));
|
|
796
|
+
out.push(bottomBorder(width));
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
}
|