pi-cc-extensions 0.8.30 → 0.8.32

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.
Files changed (53) hide show
  1. package/README.en.md +164 -174
  2. package/README.md +113 -115
  3. package/extensions/claude-code-style.ts +371 -208
  4. package/extensions/compact-thinking.ts +58 -0
  5. package/extensions/fixed-editor.ts +185 -0
  6. package/extensions/index.ts +10 -4
  7. package/extensions/subagent-notification.ts +30 -23
  8. package/node_modules/@tifan/pi-fixed-editor/README.md +36 -0
  9. package/node_modules/@tifan/pi-fixed-editor/package.json +46 -0
  10. package/node_modules/@tifan/pi-fixed-editor/src/cluster.ts +113 -0
  11. package/node_modules/@tifan/pi-fixed-editor/src/index.ts +259 -0
  12. package/node_modules/@tifan/pi-fixed-editor/src/terminal-split.ts +1337 -0
  13. package/package.json +9 -5
  14. package/extensions/ask-user-question/LICENSE +0 -21
  15. package/extensions/ask-user-question/README.md +0 -63
  16. package/extensions/ask-user-question/ask-user-question.ts +0 -266
  17. package/extensions/ask-user-question/config.ts +0 -79
  18. package/extensions/ask-user-question/events.ts +0 -55
  19. package/extensions/ask-user-question/index.ts +0 -17
  20. package/extensions/ask-user-question/reconcile.ts +0 -47
  21. package/extensions/ask-user-question/rpc-fallback.ts +0 -172
  22. package/extensions/ask-user-question/state/build-questionnaire.ts +0 -314
  23. package/extensions/ask-user-question/state/dialog-height.ts +0 -16
  24. package/extensions/ask-user-question/state/key-router.ts +0 -291
  25. package/extensions/ask-user-question/state/questionnaire-session.ts +0 -193
  26. package/extensions/ask-user-question/state/row-intent.ts +0 -143
  27. package/extensions/ask-user-question/state/selectors/contract.ts +0 -24
  28. package/extensions/ask-user-question/state/selectors/derivations.ts +0 -40
  29. package/extensions/ask-user-question/state/selectors/focus.ts +0 -17
  30. package/extensions/ask-user-question/state/selectors/projections.ts +0 -102
  31. package/extensions/ask-user-question/state/state-reducer.ts +0 -299
  32. package/extensions/ask-user-question/state/state.ts +0 -49
  33. package/extensions/ask-user-question/tool/format-answer.ts +0 -29
  34. package/extensions/ask-user-question/tool/response-envelope.ts +0 -50
  35. package/extensions/ask-user-question/tool/types.ts +0 -149
  36. package/extensions/ask-user-question/tool/validate-questionnaire.ts +0 -64
  37. package/extensions/ask-user-question/view/component-binding.ts +0 -45
  38. package/extensions/ask-user-question/view/components/inline-input.ts +0 -109
  39. package/extensions/ask-user-question/view/components/multi-select-view.ts +0 -198
  40. package/extensions/ask-user-question/view/components/option-list-view.ts +0 -77
  41. package/extensions/ask-user-question/view/components/preview/markdown-content-cache.ts +0 -76
  42. package/extensions/ask-user-question/view/components/preview/preview-block-renderer.ts +0 -114
  43. package/extensions/ask-user-question/view/components/preview/preview-box-renderer.ts +0 -86
  44. package/extensions/ask-user-question/view/components/preview/preview-layout-decider.ts +0 -219
  45. package/extensions/ask-user-question/view/components/preview/preview-pane.ts +0 -230
  46. package/extensions/ask-user-question/view/components/submit-picker.ts +0 -66
  47. package/extensions/ask-user-question/view/components/tab-bar.ts +0 -57
  48. package/extensions/ask-user-question/view/components/wrapping-select.ts +0 -307
  49. package/extensions/ask-user-question/view/dialog-builder.ts +0 -225
  50. package/extensions/ask-user-question/view/props-adapter.ts +0 -123
  51. package/extensions/ask-user-question/view/stateful-view.ts +0 -24
  52. package/extensions/ask-user-question/view/tab-components.ts +0 -16
  53. package/extensions/ask-user-question/view/tab-content-strategy.ts +0 -264
@@ -0,0 +1,58 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createJiti } from "jiti";
6
+
7
+ export type CompactThinkingConfig = {
8
+ useSummaryTitlesAsThinkingTitle: boolean;
9
+ previewLines: number;
10
+ animationIntervalMs: number;
11
+ };
12
+
13
+ export type CompactThinkingController = {
14
+ updateConfig(next: CompactThinkingConfig): void;
15
+ };
16
+
17
+ export function installCompactThinking(
18
+ pi: ExtensionAPI,
19
+ initialConfig: CompactThinkingConfig,
20
+ ): CompactThinkingController {
21
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
22
+ const configPath = join(agentDir, "compact-thinking.json");
23
+ let hasValidCompatibilityConfig = false;
24
+ try {
25
+ const value = JSON.parse(readFileSync(configPath, "utf8")) as Partial<CompactThinkingConfig>;
26
+ hasValidCompatibilityConfig =
27
+ typeof value.useSummaryTitlesAsThinkingTitle === "boolean" &&
28
+ Number.isInteger(value.previewLines) &&
29
+ (value.previewLines ?? -1) >= 0 &&
30
+ Number.isFinite(value.animationIntervalMs) &&
31
+ (value.animationIntervalMs ?? 0) >= 1;
32
+ } catch {
33
+ // The upstream loader needs a valid compatibility file on first install.
34
+ }
35
+ if (!hasValidCompatibilityConfig) {
36
+ if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
37
+ writeFileSync(configPath, `${JSON.stringify(initialConfig, null, 2)}\n`, "utf8");
38
+ }
39
+
40
+ // The package publishes TypeScript source only, so use the loader already used by fixed-editor.
41
+ const jiti = createJiti(import.meta.url);
42
+ const upstreamConfig = (
43
+ jiti("pi-compact-thinking/lib/config.ts") as { config: CompactThinkingConfig }
44
+ ).config;
45
+ const compactThinking = (
46
+ jiti("pi-compact-thinking/index.ts") as {
47
+ default: (api: ExtensionAPI) => void;
48
+ }
49
+ ).default;
50
+ Object.assign(upstreamConfig, initialConfig);
51
+ compactThinking(pi);
52
+
53
+ return {
54
+ updateConfig(next) {
55
+ Object.assign(upstreamConfig, next);
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,185 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { createJiti } from "jiti";
4
+
5
+ type LifecycleHandler = (event: any, ctx: any) => void;
6
+ type FixedEditorExtension = (pi: ExtensionAPI) => void;
7
+
8
+ // The npm package publishes TypeScript source only, so use Pi's own TS loader.
9
+ const jiti = createJiti(import.meta.url);
10
+ // Match pi-fixed-editor's own `.js` import specifier; Jiti caches `.ts` separately.
11
+ const terminalSplit = jiti("@tifan/pi-fixed-editor/src/terminal-split.js") as {
12
+ TerminalSplitCompositor?: { prototype: Record<PropertyKey, any> };
13
+ };
14
+ export type FixedEditorHitbox = { row: number; startCol: number; endCol: number };
15
+
16
+ type FixedEditorSharedState = { scrollButtonHitbox: FixedEditorHitbox | null };
17
+ const FIXED_EDITOR_STATE_KEY = Symbol.for("pi.ccstyle.fixed-editor.state");
18
+ const fixedEditorState = (() => {
19
+ const host = globalThis as typeof globalThis & {
20
+ [FIXED_EDITOR_STATE_KEY]?: FixedEditorSharedState;
21
+ };
22
+ host[FIXED_EDITOR_STATE_KEY] ??= { scrollButtonHitbox: null };
23
+ return host[FIXED_EDITOR_STATE_KEY];
24
+ })();
25
+
26
+ export function getFixedEditorScrollButtonHitbox(): FixedEditorHitbox | null {
27
+ return fixedEditorState.scrollButtonHitbox;
28
+ }
29
+
30
+ const FIXED_EDITOR_SPACING_PATCH = Symbol.for("pi.ccstyle.fixed-editor-spacing-patch");
31
+ const clusterModule = jiti("@tifan/pi-fixed-editor/src/cluster.js") as {
32
+ renderFixedEditorCluster?: (input: any) => any;
33
+ };
34
+ if (!(clusterModule as any)[FIXED_EDITOR_SPACING_PATCH] && clusterModule.renderFixedEditorCluster) {
35
+ const renderCluster = clusterModule.renderFixedEditorCluster;
36
+ const padWidgets = (lines: string[] | undefined) =>
37
+ lines?.map((line) => (line.length > 0 && !line.includes("Ctrl+End") ? ` ${line}` : line));
38
+ clusterModule.renderFixedEditorCluster = (input) => {
39
+ const aboveWidgetLines = padWidgets(input.aboveWidgetLines);
40
+ const result = renderCluster({
41
+ ...input,
42
+ // pi-fixed-editor strips Pi's leading widget spacer; restore it below the spinner.
43
+ aboveWidgetLines:
44
+ input.statusLines?.length && aboveWidgetLines?.length
45
+ ? ["", ...aboveWidgetLines]
46
+ : aboveWidgetLines,
47
+ belowWidgetLines: padWidgets(input.belowWidgetLines),
48
+ });
49
+ const buttonRow = result.lines.findIndex((line: string) => line.includes("Ctrl+End"));
50
+ if (buttonRow < 0) {
51
+ fixedEditorState.scrollButtonHitbox = null;
52
+ } else {
53
+ const plain = result.lines[buttonRow]
54
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
55
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
56
+ const start = plain.indexOf("[");
57
+ fixedEditorState.scrollButtonHitbox = {
58
+ row: input.terminalRows - result.lines.length + buttonRow + 1,
59
+ startCol: visibleWidth(plain.slice(0, Math.max(0, start))) + 1,
60
+ endCol: visibleWidth(plain.trimEnd()),
61
+ };
62
+ }
63
+ return result;
64
+ };
65
+ (clusterModule as any)[FIXED_EDITOR_SPACING_PATCH] = true;
66
+ }
67
+ const loaded = jiti("@tifan/pi-fixed-editor/src/index.ts") as {
68
+ default?: FixedEditorExtension;
69
+ };
70
+ const fixedEditor = loaded.default ?? (loaded as unknown as FixedEditorExtension);
71
+
72
+ const IME_CURSOR_PATCH = Symbol.for("pi.ccstyle.fixed-editor-ime-cursor-patch");
73
+
74
+ function positionHiddenImeCursor(compositor: any): void {
75
+ if (
76
+ compositor.disposed ||
77
+ compositor.writing ||
78
+ compositor.getShowHardwareCursor?.() ||
79
+ compositor.hasVisibleOverlay?.()
80
+ )
81
+ return;
82
+ const rows = compositor.getRawRows?.();
83
+ if (!Number.isFinite(rows)) return;
84
+ const width = Math.max(1, Number(compositor.terminal?.columns) || 80);
85
+ const cluster = compositor.getCluster?.(width, rows);
86
+ if (!cluster?.cursor) return;
87
+ const row = Math.max(1, rows - cluster.lines.length + cluster.cursor.row + 1);
88
+ const col = Math.max(1, cluster.cursor.col + 1);
89
+ compositor.originalWrite?.(`\x1b[?2026h\x1b[${row};${col}H\x1b[?25l\x1b[?2026l`);
90
+ }
91
+
92
+ export function installFixedEditorImePatch(compositorClass: {
93
+ prototype: Record<PropertyKey, any>;
94
+ }): void {
95
+ const prototype = compositorClass.prototype;
96
+ if (prototype[IME_CURSOR_PATCH]) return;
97
+ for (const name of ["write", "requestRepaint"] as const) {
98
+ const original = prototype[name];
99
+ if (typeof original !== "function") continue;
100
+ prototype[name] = function (this: any, ...args: any[]) {
101
+ const result = Reflect.apply(original, this, args);
102
+ positionHiddenImeCursor(this);
103
+ return result;
104
+ };
105
+ }
106
+ prototype[IME_CURSOR_PATCH] = true;
107
+ }
108
+
109
+ if (terminalSplit.TerminalSplitCompositor) {
110
+ installFixedEditorImePatch(terminalSplit.TerminalSplitCompositor);
111
+ }
112
+
113
+ export type FixedEditorController = {
114
+ setEnabled(enabled: boolean): void;
115
+ };
116
+
117
+ type FixedEditorOwner = {
118
+ owner: object;
119
+ stop: () => void;
120
+ };
121
+
122
+ const FIXED_EDITOR_OWNER = Symbol.for("pi.ccstyle.fixed-editor-owner");
123
+
124
+ /** Adds runtime on/off control around pi-fixed-editor's session lifecycle. */
125
+ export function installFixedEditor(
126
+ pi: ExtensionAPI,
127
+ initiallyEnabled: boolean,
128
+ ): FixedEditorController {
129
+ let start: LifecycleHandler | undefined;
130
+ let shutdown: LifecycleHandler | undefined;
131
+ fixedEditor({
132
+ on(event: string, handler: LifecycleHandler) {
133
+ if (event === "session_start") start = handler;
134
+ if (event === "session_shutdown") shutdown = handler;
135
+ },
136
+ } as unknown as ExtensionAPI);
137
+
138
+ const owner = {};
139
+ let enabled = initiallyEnabled;
140
+ let active = false;
141
+ let session: { event: any; ctx: any } | undefined;
142
+
143
+ const deactivate = () => {
144
+ fixedEditorState.scrollButtonHitbox = null;
145
+ const current = (globalThis as any)[FIXED_EDITOR_OWNER] as FixedEditorOwner | undefined;
146
+ if (current?.owner === owner) current.stop();
147
+ else active = false;
148
+ };
149
+ const activate = () => {
150
+ if (!session || session.ctx?.mode !== "tui" || active) return;
151
+ const previous = (globalThis as any)[FIXED_EDITOR_OWNER] as FixedEditorOwner | undefined;
152
+ if (previous?.owner !== owner) previous?.stop();
153
+ const currentSession = session;
154
+ start?.(currentSession.event, currentSession.ctx);
155
+ active = true;
156
+ (globalThis as any)[FIXED_EDITOR_OWNER] = {
157
+ owner,
158
+ stop: () => {
159
+ if (!active) return;
160
+ shutdown?.(currentSession.event, currentSession.ctx);
161
+ active = false;
162
+ if ((globalThis as any)[FIXED_EDITOR_OWNER]?.owner === owner) {
163
+ delete (globalThis as any)[FIXED_EDITOR_OWNER];
164
+ }
165
+ },
166
+ } satisfies FixedEditorOwner;
167
+ };
168
+
169
+ pi.on("session_start", (event, ctx) => {
170
+ session = { event, ctx };
171
+ if (enabled) activate();
172
+ });
173
+ pi.on("session_shutdown", () => {
174
+ deactivate();
175
+ session = undefined;
176
+ });
177
+
178
+ return {
179
+ setEnabled(nextEnabled) {
180
+ enabled = nextEnabled;
181
+ if (nextEnabled) activate();
182
+ else deactivate();
183
+ },
184
+ };
185
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import agentAutocomplete from "./agent-autocomplete.ts";
3
- import askUserQuestion from "./ask-user-question/index.ts";
4
- import claudeCodeStyle from "./claude-code-style.ts";
3
+ import claudeCodeStyle, { getCompactThinkingConfig } from "./claude-code-style.ts";
4
+ import { installCompactThinking, type CompactThinkingController } from "./compact-thinking.ts";
5
5
  import context from "./context.ts";
6
6
  import piAliases from "./pi-aliases.ts";
7
7
  import piStartupHeader from "./pi-startup-header.ts";
@@ -12,11 +12,17 @@ import workingMessage from "./working-message.ts";
12
12
  export default function (pi: ExtensionAPI): void {
13
13
  piAliases(pi);
14
14
  piStartupHeader(pi);
15
- claudeCodeStyle(pi);
15
+ let compactThinking: CompactThinkingController | undefined;
16
+ const compactThinkingBridge: CompactThinkingController = {
17
+ updateConfig(next) {
18
+ compactThinking?.updateConfig(next);
19
+ },
20
+ };
21
+ claudeCodeStyle(pi, undefined, compactThinkingBridge);
22
+ compactThinking = installCompactThinking(pi, getCompactThinkingConfig());
16
23
  subagentNotification(pi);
17
24
  workingMessage(pi);
18
25
  context(pi);
19
26
  sessionReference(pi);
20
27
  agentAutocomplete(pi);
21
- askUserQuestion(pi);
22
28
  }
@@ -84,31 +84,38 @@ export function formatSubagentNotificationLines(lines: string[], width: number):
84
84
  }
85
85
 
86
86
  export default function subagentNotificationExtension(pi: ExtensionAPI): void {
87
- const prototype = CustomMessageComponent.prototype as any;
88
- const host = globalThis as any;
89
- const previous = host[PATCH_KEY] as Patch | undefined;
90
- if (previous) previous.active = false;
91
- const originalRender =
92
- previous && prototype.render === previous.installedRender
93
- ? previous.originalRender
94
- : prototype.render;
95
- const patch = {
96
- active: true,
97
- prototype,
98
- originalRender,
99
- installedRender: undefined as any,
100
- } satisfies Patch;
101
- patch.installedRender = function (this: any, width: number): string[] {
102
- const lines = originalRender.call(this, width);
103
- if (!patch.active || this?.message?.customType !== "subagent-notification") return lines;
104
- return formatSubagentNotificationLines(lines, width);
105
- };
106
- prototype.render = patch.installedRender;
107
- host[PATCH_KEY] = patch;
87
+ let patch: Patch | undefined;
88
+
89
+ pi.on("session_start", async (_event, ctx) => {
90
+ if (patch || ctx?.mode !== "tui" || !ctx?.hasUI) return;
91
+ const prototype = CustomMessageComponent.prototype as any;
92
+ const host = globalThis as any;
93
+ const previous = host[PATCH_KEY] as Patch | undefined;
94
+ if (previous) previous.active = false;
95
+ const originalRender =
96
+ previous && prototype.render === previous.installedRender
97
+ ? previous.originalRender
98
+ : prototype.render;
99
+ patch = {
100
+ active: true,
101
+ prototype,
102
+ originalRender,
103
+ installedRender: undefined as any,
104
+ };
105
+ const installed = patch;
106
+ installed.installedRender = function (this: any, width: number): string[] {
107
+ const lines = originalRender.call(this, width);
108
+ if (!installed.active || this?.message?.customType !== "subagent-notification") return lines;
109
+ return formatSubagentNotificationLines(lines, width);
110
+ };
111
+ prototype.render = installed.installedRender;
112
+ host[PATCH_KEY] = installed;
113
+ });
108
114
 
109
115
  pi.on("session_shutdown", async () => {
110
- if (!patch.active) return;
116
+ if (!patch?.active) return;
111
117
  patch.active = false;
112
- if (prototype.render === patch.installedRender) prototype.render = originalRender;
118
+ if (patch.prototype.render === patch.installedRender)
119
+ patch.prototype.render = patch.originalRender;
113
120
  });
114
121
  }
@@ -0,0 +1,36 @@
1
+ # @tifan/pi-fixed-editor
2
+
3
+ Keep Pi's editor, footer, and editor-adjacent widgets fixed at the bottom of the terminal while the transcript scrolls.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@tifan/pi-fixed-editor
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ - Pins Pi's existing editor/footer area to the bottom of the terminal.
14
+ - Keeps `aboveEditor` and `belowEditor` widgets with the editor.
15
+ - Preserves existing editor behavior, footer content, and autocomplete.
16
+ - Routes mouse wheel and PageUp/PageDown scrolling to the transcript.
17
+ - Jumps the transcript back to the bottom when you submit a query with Enter.
18
+
19
+ ## What it does not do
20
+
21
+ - No styling.
22
+ - No powerline footer.
23
+ - No commands.
24
+ - No settings.
25
+ - No custom shortcuts.
26
+ - No print or RPC mode behavior.
27
+
28
+ Uninstall or disable the extension to return to Pi's default scrolling behavior.
29
+
30
+ ## Credits
31
+
32
+ Fixed terminal-region behavior is adapted from [pi-powerline-footer](https://github.com/nicobailon/pi-powerline-footer) by Nico Bailon.
33
+
34
+ ## License
35
+
36
+ [MIT](LICENSE)
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@tifan/pi-fixed-editor",
3
+ "version": "0.2.5",
4
+ "description": "Keep the pi editor and footer fixed while the transcript scrolls.",
5
+ "keywords": [
6
+ "fixed-editor",
7
+ "pi",
8
+ "pi-extension",
9
+ "pi-package",
10
+ "terminal"
11
+ ],
12
+ "homepage": "https://github.com/tifandotme/pi-extensions/tree/master/packages/pi-fixed-editor#readme",
13
+ "bugs": "https://github.com/tifandotme/pi-extensions/issues",
14
+ "license": "MIT",
15
+ "author": "Tifan Dwi Avianto <hi@tifan.me>",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/tifandotme/pi-extensions.git",
19
+ "directory": "packages/pi-fixed-editor"
20
+ },
21
+ "files": [
22
+ "src/**/*.ts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "type": "module",
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "scripts": {
32
+ "test": "node --experimental-strip-types --test tests/**/*.test.ts"
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": ">=0.78.0",
36
+ "@earendil-works/pi-tui": ">=0.78.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "pi": {
42
+ "extensions": [
43
+ "./src/index.ts"
44
+ ]
45
+ }
46
+ }
@@ -0,0 +1,113 @@
1
+ import {
2
+ CURSOR_MARKER,
3
+ truncateToWidth,
4
+ visibleWidth,
5
+ } from "@earendil-works/pi-tui"
6
+
7
+ export interface FixedEditorClusterInput {
8
+ width: number
9
+ terminalRows: number
10
+ statusLines?: string[]
11
+ aboveWidgetLines?: string[]
12
+ editorLines: string[]
13
+ belowWidgetLines?: string[]
14
+ footerLines?: string[]
15
+ }
16
+
17
+ export interface FixedEditorCursor {
18
+ row: number
19
+ col: number
20
+ }
21
+
22
+ export interface FixedEditorClusterRender {
23
+ lines: string[]
24
+ cursor: FixedEditorCursor | null
25
+ }
26
+
27
+ function normalizeLines(lines: string[] | undefined, width: number): string[] {
28
+ if (!lines || width <= 0) return []
29
+
30
+ return lines
31
+ .filter((line) => line !== undefined && line !== null)
32
+ .map((line) =>
33
+ visibleWidth(line) > width
34
+ ? truncateToWidth(line, width, "", true)
35
+ : line,
36
+ )
37
+ }
38
+
39
+ function takeTail(lines: string[], count: number): string[] {
40
+ if (count <= 0) return []
41
+ return lines.length <= count ? lines : lines.slice(lines.length - count)
42
+ }
43
+
44
+ function capEditorLines(lines: string[], count: number): string[] {
45
+ if (count <= 0) return []
46
+ if (lines.length <= count) return lines
47
+
48
+ const cursorRow = lines.findIndex((line) => line.includes(CURSOR_MARKER))
49
+ if (cursorRow !== -1) {
50
+ const start = Math.max(
51
+ 0,
52
+ Math.min(cursorRow - count + 1, lines.length - count),
53
+ )
54
+ return lines.slice(start, start + count)
55
+ }
56
+
57
+ return lines.slice(lines.length - count)
58
+ }
59
+
60
+ function extractCursor(lines: string[]): FixedEditorClusterRender {
61
+ let cursor: FixedEditorCursor | null = null
62
+ const cleaned = lines.map((line, row) => {
63
+ const markerIndex = line.indexOf(CURSOR_MARKER)
64
+ if (markerIndex === -1) return line
65
+
66
+ cursor ??= {
67
+ row,
68
+ col: visibleWidth(line.slice(0, markerIndex)),
69
+ }
70
+
71
+ return (
72
+ line.slice(0, markerIndex) +
73
+ line.slice(markerIndex + CURSOR_MARKER.length)
74
+ )
75
+ })
76
+
77
+ return { lines: cleaned, cursor }
78
+ }
79
+
80
+ export function renderFixedEditorCluster(
81
+ input: FixedEditorClusterInput,
82
+ ): FixedEditorClusterRender {
83
+ const width = Math.max(1, input.width)
84
+ const maxRows = Math.max(1, input.terminalRows - 1)
85
+
86
+ const statusLines = normalizeLines(input.statusLines, width)
87
+ const aboveWidgetLines = normalizeLines(input.aboveWidgetLines, width)
88
+ const editorSource = normalizeLines(input.editorLines, width)
89
+ const belowWidgetLines = normalizeLines(input.belowWidgetLines, width)
90
+ const footerLines = normalizeLines(input.footerLines, width)
91
+
92
+ const editorLines = capEditorLines(editorSource, maxRows)
93
+ let remaining = maxRows - editorLines.length
94
+
95
+ const footer = takeTail(footerLines, remaining)
96
+ remaining -= footer.length
97
+
98
+ const belowWidgets = takeTail(belowWidgetLines, remaining)
99
+ remaining -= belowWidgets.length
100
+
101
+ const aboveWidgets = takeTail(aboveWidgetLines, remaining)
102
+ remaining -= aboveWidgets.length
103
+
104
+ const status = takeTail(statusLines, remaining)
105
+
106
+ return extractCursor([
107
+ ...status,
108
+ ...aboveWidgets,
109
+ ...editorLines,
110
+ ...belowWidgets,
111
+ ...footer,
112
+ ])
113
+ }