@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17
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 +78 -0
- package/dist/cli.js +3531 -3812
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +23 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/session-picker.ts +17 -3
- package/src/cli/usage-cli.ts +5 -1
- package/src/commands/launch.ts +3 -3
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/config/settings-schema.ts +13 -1
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +21 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +16 -0
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/todo.md +1 -1
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +422 -97
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/parallel.ts +6 -1
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +29 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/utils/tools-manager.ts +67 -10
- package/src/web/search/providers/exa.ts +85 -1
|
@@ -4,6 +4,7 @@ import { getEditorTheme, initTheme } from "../theme/theme";
|
|
|
4
4
|
import {
|
|
5
5
|
CustomEditor,
|
|
6
6
|
extractBracketedImagePastePaths,
|
|
7
|
+
extractBracketedPastePaths,
|
|
7
8
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
8
9
|
SPACE_HOLD_RELEASE_MS,
|
|
9
10
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -73,7 +74,7 @@ describe("CustomEditor placeholder decoration", () => {
|
|
|
73
74
|
});
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
describe("CustomEditor bracketed
|
|
77
|
+
describe("CustomEditor bracketed path paste", () => {
|
|
77
78
|
it("leaves a pasted bare .png filename on the normal text path", () => {
|
|
78
79
|
expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
|
|
79
80
|
});
|
|
@@ -86,6 +87,22 @@ describe("CustomEditor bracketed image-path paste", () => {
|
|
|
86
87
|
"C:\\Users\\me\\icon-photo-default.png",
|
|
87
88
|
]);
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
it("extracts explicit non-image paths without classifying them as image paths", () => {
|
|
92
|
+
expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
|
|
93
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("routes non-image path pastes through the file-path hook", async () => {
|
|
97
|
+
const { editor } = makeEditor();
|
|
98
|
+
const pasted = Promise.withResolvers<string>();
|
|
99
|
+
editor.onPasteFilePath = path => pasted.resolve(path);
|
|
100
|
+
|
|
101
|
+
editor.handleInput(bracketedPaste("/tmp/report.csv"));
|
|
102
|
+
|
|
103
|
+
expect(await pasted.promise).toBe("/tmp/report.csv");
|
|
104
|
+
expect(editor.getText()).toBe("");
|
|
105
|
+
});
|
|
89
106
|
});
|
|
90
107
|
|
|
91
108
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
@@ -62,7 +62,6 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
|
|
|
62
62
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
63
63
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
64
64
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
65
|
-
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
|
|
66
65
|
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
67
66
|
const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
68
67
|
const FILE_URI_REGEX = /^file:\/\//i;
|
|
@@ -100,19 +99,7 @@ function isPastedPathSeparator(char: string | undefined): boolean {
|
|
|
100
99
|
return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
function
|
|
104
|
-
const quote = payload[segmentStart];
|
|
105
|
-
const afterExtension = payload[extensionEnd];
|
|
106
|
-
if (quote === '"' || quote === "'") {
|
|
107
|
-
return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
|
|
108
|
-
? extensionEnd + 1
|
|
109
|
-
: undefined;
|
|
110
|
-
}
|
|
111
|
-
if (isPastedPathSeparator(afterExtension)) return extensionEnd;
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizePastedImagePath(path: string): string {
|
|
102
|
+
function normalizePastedPath(path: string): string {
|
|
116
103
|
const trimmed = path.trim();
|
|
117
104
|
const first = trimmed[0];
|
|
118
105
|
const last = trimmed[trimmed.length - 1];
|
|
@@ -121,13 +108,60 @@ function normalizePastedImagePath(path: string): string {
|
|
|
121
108
|
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
122
109
|
}
|
|
123
110
|
|
|
124
|
-
function
|
|
111
|
+
function isExplicitPastedPath(path: string): boolean {
|
|
125
112
|
if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
|
|
126
113
|
if (URI_SCHEME_REGEX.test(path)) return false;
|
|
127
114
|
return path.includes("/") || path.includes("\\");
|
|
128
115
|
}
|
|
129
116
|
|
|
130
|
-
|
|
117
|
+
function isImagePath(path: string): boolean {
|
|
118
|
+
return BRACKETED_IMAGE_PATH_REGEX.test(path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function splitPastedPathSegments(payload: string): string[] | undefined {
|
|
122
|
+
const segments: string[] = [];
|
|
123
|
+
let segment = "";
|
|
124
|
+
let quote: string | undefined;
|
|
125
|
+
let escaped = false;
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < payload.length; i++) {
|
|
128
|
+
const char = payload[i];
|
|
129
|
+
if (escaped) {
|
|
130
|
+
segment += char;
|
|
131
|
+
escaped = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (char === "\\") {
|
|
135
|
+
segment += char;
|
|
136
|
+
escaped = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (quote) {
|
|
140
|
+
segment += char;
|
|
141
|
+
if (char === quote) quote = undefined;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (char === '"' || char === "'") {
|
|
145
|
+
segment += char;
|
|
146
|
+
quote = char;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (isPastedPathSeparator(char)) {
|
|
150
|
+
if (segment) {
|
|
151
|
+
segments.push(segment);
|
|
152
|
+
segment = "";
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
segment += char;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (escaped || quote) return undefined;
|
|
160
|
+
if (segment) segments.push(segment);
|
|
161
|
+
return segments.length > 0 ? segments : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function extractBracketedPastePaths(data: string): string[] | undefined {
|
|
131
165
|
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
132
166
|
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
133
167
|
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
|
@@ -135,33 +169,23 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
|
|
|
135
169
|
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
|
|
136
170
|
if (!pasted) return undefined;
|
|
137
171
|
|
|
172
|
+
const segments = splitPastedPathSegments(pasted);
|
|
173
|
+
if (!segments) return undefined;
|
|
174
|
+
|
|
138
175
|
const paths: string[] = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
|
|
143
|
-
match;
|
|
144
|
-
match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
|
|
145
|
-
) {
|
|
146
|
-
const extensionEnd = match.index + match[0].length;
|
|
147
|
-
const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
|
|
148
|
-
if (boundaryEnd === undefined) continue;
|
|
149
|
-
|
|
150
|
-
const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
|
|
151
|
-
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
|
|
176
|
+
for (const segment of segments) {
|
|
177
|
+
const path = normalizePastedPath(segment);
|
|
178
|
+
if (!path || !isExplicitPastedPath(path)) return undefined;
|
|
152
179
|
paths.push(path);
|
|
153
|
-
|
|
154
|
-
segmentStart = boundaryEnd;
|
|
155
|
-
while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
|
|
156
|
-
segmentStart++;
|
|
157
|
-
}
|
|
158
|
-
BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
|
|
159
180
|
}
|
|
160
|
-
|
|
161
|
-
if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
|
|
162
181
|
return paths;
|
|
163
182
|
}
|
|
164
183
|
|
|
184
|
+
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
185
|
+
const paths = extractBracketedPastePaths(data);
|
|
186
|
+
return paths?.every(isImagePath) ? paths : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
export function extractBracketedImagePastePath(data: string): string | undefined {
|
|
166
190
|
const paths = extractBracketedImagePastePaths(data);
|
|
167
191
|
return paths?.length === 1 ? paths[0] : undefined;
|
|
@@ -294,6 +318,8 @@ export class CustomEditor extends Editor {
|
|
|
294
318
|
onPasteImage?: () => Promise<boolean>;
|
|
295
319
|
/** Called when a bracketed paste contains one or more image-file paths. */
|
|
296
320
|
onPasteImagePath?: (path: string) => void | Promise<void>;
|
|
321
|
+
/** Called when a bracketed paste contains one or more non-image file paths. */
|
|
322
|
+
onPasteFilePath?: (path: string) => void | Promise<void>;
|
|
297
323
|
/** Called when the configured raw text-paste shortcut is pressed. */
|
|
298
324
|
onPasteTextRaw?: () => void;
|
|
299
325
|
/** Called when the configured dequeue shortcut is pressed. */
|
|
@@ -479,14 +505,20 @@ export class CustomEditor extends Editor {
|
|
|
479
505
|
return;
|
|
480
506
|
}
|
|
481
507
|
|
|
482
|
-
const
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
508
|
+
const pastedPaths = extractBracketedPastePaths(data);
|
|
509
|
+
if (pastedPaths) {
|
|
510
|
+
const canHandlePaths = pastedPaths.every(path =>
|
|
511
|
+
isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined,
|
|
512
|
+
);
|
|
513
|
+
if (canHandlePaths) {
|
|
514
|
+
void (async () => {
|
|
515
|
+
for (const path of pastedPaths) {
|
|
516
|
+
if (isImagePath(path)) await this.onPasteImagePath?.(path);
|
|
517
|
+
else await this.onPasteFilePath?.(path);
|
|
518
|
+
}
|
|
519
|
+
})();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
490
522
|
}
|
|
491
523
|
|
|
492
524
|
const parsedKey = parseKey(data);
|
|
@@ -67,7 +67,7 @@ export class HookEditorComponent extends Container {
|
|
|
67
67
|
|
|
68
68
|
// Hint
|
|
69
69
|
const hint = this.#promptStyle
|
|
70
|
-
? "enter submit esc cancel ctrl+g external editor"
|
|
70
|
+
? "enter or ctrl+q submit esc cancel ctrl+g external editor"
|
|
71
71
|
: "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
|
|
72
72
|
this.addChild(new Text(theme.fg("dim", hint), 1, 0));
|
|
73
73
|
|
|
@@ -95,8 +95,21 @@ export class HookEditorComponent extends Container {
|
|
|
95
95
|
this.#editor.pasteText(text);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Prompt-style: raw Enter submits; Editor owns newline-producing sequences.
|
|
100
|
+
* The follow-up chord (`app.message.followUp` → Ctrl+Q / Ctrl+Enter) also
|
|
101
|
+
* submits, so muscle memory from the main editor / hook-style surface works
|
|
102
|
+
* here and Windows Terminal — which can't deliver a distinct Ctrl+Enter
|
|
103
|
+
* event (#1903) — still has a working chord via Ctrl+Q (#3353).
|
|
104
|
+
*/
|
|
99
105
|
#handlePromptStyleInput(keyData: string): void {
|
|
106
|
+
// Submit on the follow-up chord first so it wins over Editor's own
|
|
107
|
+
// Ctrl+Enter newline handling. Mirrors #handleHookStyleInput.
|
|
108
|
+
if (matchesAppFollowUp(keyData)) {
|
|
109
|
+
this.#submitCurrentText();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// Prompt-style keeps Escape as an explicit cancel key and also honors app.interrupt remaps.
|
|
101
114
|
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesAppInterrupt(keyData)) {
|
|
102
115
|
this.#onCancelCallback();
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import {
|
|
12
12
|
Container,
|
|
13
13
|
Input,
|
|
14
|
+
matchesKey,
|
|
14
15
|
type SelectItem,
|
|
15
16
|
SelectList,
|
|
16
17
|
type SettingItem,
|
|
@@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border";
|
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
|
|
40
|
+
*
|
|
41
|
+
* Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the
|
|
42
|
+
* fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty),
|
|
43
|
+
* where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`.
|
|
44
|
+
* The literal fallbacks preserve legacy single/double-escape on terminals without it.
|
|
39
45
|
*/
|
|
40
46
|
export function handleInputOrEscape(
|
|
41
47
|
data: string,
|
|
42
48
|
input: { handleInput(data: string): void },
|
|
43
49
|
onCancel: () => void,
|
|
44
50
|
): void {
|
|
45
|
-
if (data === "\x1b" || data === "\x1b\x1b") {
|
|
51
|
+
if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) {
|
|
46
52
|
onCancel();
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Input,
|
|
6
6
|
matchesKey,
|
|
7
7
|
padding,
|
|
8
|
+
parseSgrMouse,
|
|
8
9
|
replaceTabs,
|
|
9
10
|
ScrollView,
|
|
10
11
|
Spacer,
|
|
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
|
|
|
161
162
|
class SessionList implements Component {
|
|
162
163
|
#filteredSessions: SessionInfo[] = [];
|
|
163
164
|
#selectedIndex: number = 0;
|
|
165
|
+
// Maps a 0-based line within this list's own render to a filtered-session
|
|
166
|
+
// index, or undefined for chrome rows (search line, blanks, scrollbar gap).
|
|
167
|
+
// Rebuilt every render so the picker's mouse hit-testing tracks the live
|
|
168
|
+
// scroll window. Only consulted while the picker holds the alternate screen
|
|
169
|
+
// (where the overlay enables mouse tracking and paints from screen row 0).
|
|
170
|
+
#hitRows: (number | undefined)[] = [];
|
|
164
171
|
readonly #searchInput: Input;
|
|
165
172
|
onSelect?: (session: SessionInfo) => void;
|
|
166
173
|
onCancel?: () => void;
|
|
@@ -257,12 +264,32 @@ class SessionList implements Component {
|
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
/** Resolve a list-local rendered-line index to a filtered-session index. */
|
|
268
|
+
hitTestSession(line: number): number | undefined {
|
|
269
|
+
return this.#hitRows[line];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Wheel notch: move the selection one step (clamped, no wrap). */
|
|
273
|
+
handleWheel(delta: -1 | 1): void {
|
|
274
|
+
if (this.#filteredSessions.length === 0) return;
|
|
275
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Mouse click: select the session under the pointer and resume it. */
|
|
279
|
+
selectAndConfirm(index: number): void {
|
|
280
|
+
const session = this.#filteredSessions[index];
|
|
281
|
+
if (!session) return;
|
|
282
|
+
this.#selectedIndex = index;
|
|
283
|
+
this.onSelect?.(session);
|
|
284
|
+
}
|
|
285
|
+
|
|
260
286
|
invalidate(): void {
|
|
261
287
|
// No cached state to invalidate currently
|
|
262
288
|
}
|
|
263
289
|
|
|
264
290
|
render(width: number): readonly string[] {
|
|
265
291
|
const lines: string[] = [];
|
|
292
|
+
this.#hitRows = [];
|
|
266
293
|
|
|
267
294
|
// Render search input
|
|
268
295
|
lines.push(...this.#searchInput.render(width));
|
|
@@ -311,9 +338,11 @@ class SessionList implements Component {
|
|
|
311
338
|
// Each session block is built into sessionLines, then wrapped by ScrollView
|
|
312
339
|
// so the right-edge scrollbar is proportional at the physical-line level.
|
|
313
340
|
const sessionLines: string[] = [];
|
|
341
|
+
const sessionRowIndex: number[] = [];
|
|
314
342
|
const overflow = this.#filteredSessions.length > maxVisible;
|
|
315
343
|
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
316
344
|
for (let i = startIndex; i < endIndex; i++) {
|
|
345
|
+
const blockStart = sessionLines.length;
|
|
317
346
|
const session = this.#filteredSessions[i];
|
|
318
347
|
const isSelected = i === this.#selectedIndex;
|
|
319
348
|
|
|
@@ -363,6 +392,7 @@ class SessionList implements Component {
|
|
|
363
392
|
|
|
364
393
|
sessionLines.push(metadataLine);
|
|
365
394
|
sessionLines.push(""); // Blank line between sessions
|
|
395
|
+
for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
|
|
366
396
|
}
|
|
367
397
|
|
|
368
398
|
// Wrap the rendered window in a ScrollView for a proportional right-edge bar.
|
|
@@ -375,16 +405,10 @@ class SessionList implements Component {
|
|
|
375
405
|
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
376
406
|
});
|
|
377
407
|
sv.setScrollOffset(Math.round(startIndex * linesPerItem));
|
|
378
|
-
lines.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
lines.push(
|
|
382
|
-
lines.push(
|
|
383
|
-
theme.fg(
|
|
384
|
-
"muted",
|
|
385
|
-
` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
|
|
386
|
-
),
|
|
387
|
-
);
|
|
408
|
+
const sessionRegionStart = lines.length;
|
|
409
|
+
const svLines = sv.render(width);
|
|
410
|
+
for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
|
|
411
|
+
lines.push(...svLines);
|
|
388
412
|
|
|
389
413
|
return lines;
|
|
390
414
|
}
|
|
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
|
|
|
462
486
|
* Omitted only in tests; defaults to a conservative 24 rows.
|
|
463
487
|
*/
|
|
464
488
|
getTerminalRows?: () => number;
|
|
489
|
+
/**
|
|
490
|
+
* Fill the whole viewport and pin the footer (hint + bottom border) to the
|
|
491
|
+
* last rows, so the footer stops drifting as the list window changes height.
|
|
492
|
+
* Set by the standalone `--resume` picker (fullscreen alternate screen); the
|
|
493
|
+
* in-editor selector leaves it off and renders compactly.
|
|
494
|
+
*/
|
|
495
|
+
fillHeight?: boolean;
|
|
465
496
|
}
|
|
466
497
|
|
|
467
498
|
/**
|
|
@@ -470,6 +501,13 @@ export interface SessionSelectorOptions {
|
|
|
470
501
|
export class SessionSelectorComponent extends Container {
|
|
471
502
|
#sessionList: SessionList;
|
|
472
503
|
#confirmationDialog: HookSelectorComponent | null = null;
|
|
504
|
+
// Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
|
|
505
|
+
// frame. The delete dialog REPLACES the list in this slot rather than being
|
|
506
|
+
// appended below the picker chrome, so the picker is always
|
|
507
|
+
// `chrome + max(list, dialog) + chrome` and never overflows the viewport
|
|
508
|
+
// (issue #3283: an overflowing dialog frame committed the header into
|
|
509
|
+
// scrollback, stranding it above the viewport once the dialog closed).
|
|
510
|
+
#contentSlot: Container;
|
|
473
511
|
#messageContainer: Container;
|
|
474
512
|
#headerText: Text;
|
|
475
513
|
#onDelete?: (session: SessionInfo) => Promise<boolean>;
|
|
@@ -479,6 +517,18 @@ export class SessionSelectorComponent extends Container {
|
|
|
479
517
|
#globalSessions: SessionInfo[] | null = null;
|
|
480
518
|
#scope: "folder" | "all" = "folder";
|
|
481
519
|
#toggling = false;
|
|
520
|
+
// 0-based line where the session list begins within this component's own
|
|
521
|
+
// render, captured each frame. The fullscreen picker overlay paints from
|
|
522
|
+
// screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
|
|
523
|
+
// list. Only meaningful while the picker holds the alternate screen.
|
|
524
|
+
#listLineOffset = 0;
|
|
525
|
+
// 0-based line where the pinned footer begins; clicks at or below it never
|
|
526
|
+
// hit-test the list, so a footer click on a cramped (trimmed) frame can't
|
|
527
|
+
// resume a session scrolled off-screen.
|
|
528
|
+
#footerStart = 0;
|
|
529
|
+
readonly #getTerminalRows: () => number;
|
|
530
|
+
readonly #fillHeight: boolean;
|
|
531
|
+
readonly #bottomBorder = new DynamicBorder();
|
|
482
532
|
|
|
483
533
|
constructor(
|
|
484
534
|
sessions: SessionInfo[],
|
|
@@ -494,6 +544,8 @@ export class SessionSelectorComponent extends Container {
|
|
|
494
544
|
this.#loadAllSessions = options.loadAllSessions;
|
|
495
545
|
this.#folderSessions = sessions;
|
|
496
546
|
this.#globalSessions = options.allSessions ?? null;
|
|
547
|
+
this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
|
|
548
|
+
this.#fillHeight = options.fillHeight ?? false;
|
|
497
549
|
// Add header
|
|
498
550
|
this.addChild(new Spacer(1));
|
|
499
551
|
this.#headerText = new Text(this.#headerLabel(), 1, 0);
|
|
@@ -517,11 +569,9 @@ export class SessionSelectorComponent extends Container {
|
|
|
517
569
|
void this.#toggleScope();
|
|
518
570
|
};
|
|
519
571
|
}
|
|
520
|
-
this
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.addChild(new Spacer(1));
|
|
524
|
-
this.addChild(new DynamicBorder());
|
|
572
|
+
this.#contentSlot = new Container();
|
|
573
|
+
this.#contentSlot.addChild(this.#sessionList);
|
|
574
|
+
this.addChild(this.#contentSlot);
|
|
525
575
|
}
|
|
526
576
|
|
|
527
577
|
#headerLabel(): string {
|
|
@@ -582,6 +632,15 @@ export class SessionSelectorComponent extends Container {
|
|
|
582
632
|
|
|
583
633
|
#showDeleteConfirmation(session: SessionInfo): void {
|
|
584
634
|
const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
|
|
635
|
+
const closeDialog = () => {
|
|
636
|
+
this.#confirmationDialog = null;
|
|
637
|
+
// Restore the SessionList into the content slot so the picker is back
|
|
638
|
+
// to its normal layout on the very next render — the same frame the
|
|
639
|
+
// dialog disappears.
|
|
640
|
+
this.#contentSlot.clear();
|
|
641
|
+
this.#contentSlot.addChild(this.#sessionList);
|
|
642
|
+
this.#onRequestRender?.();
|
|
643
|
+
};
|
|
585
644
|
this.#confirmationDialog = new HookSelectorComponent(
|
|
586
645
|
`Delete session?\n${displayName}`,
|
|
587
646
|
["Yes", "No"],
|
|
@@ -597,25 +656,61 @@ export class SessionSelectorComponent extends Container {
|
|
|
597
656
|
this.#showError(err instanceof Error ? err.message : String(err));
|
|
598
657
|
}
|
|
599
658
|
}
|
|
600
|
-
|
|
601
|
-
this.removeChild(this.#confirmationDialog!);
|
|
602
|
-
this.#confirmationDialog = null;
|
|
603
|
-
// Request rerender
|
|
604
|
-
this.#onRequestRender?.();
|
|
605
|
-
},
|
|
606
|
-
() => {
|
|
607
|
-
// Cancel - close confirmation dialog
|
|
608
|
-
this.removeChild(this.#confirmationDialog!);
|
|
609
|
-
this.#confirmationDialog = null;
|
|
610
|
-
// Request rerender
|
|
611
|
-
this.#onRequestRender?.();
|
|
659
|
+
closeDialog();
|
|
612
660
|
},
|
|
661
|
+
closeDialog,
|
|
613
662
|
);
|
|
614
|
-
//
|
|
615
|
-
|
|
663
|
+
// Swap the SessionList out of the content slot and mount the dialog in its
|
|
664
|
+
// place: the dialog competes only with the SessionList's rendered budget,
|
|
665
|
+
// never the SessionList AND the picker chrome, so the picker frame stays
|
|
666
|
+
// inside the terminal viewport and the TUI never commits the header into
|
|
667
|
+
// scrollback (issue #3283).
|
|
668
|
+
this.#contentSlot.clear();
|
|
669
|
+
this.#contentSlot.addChild(this.#confirmationDialog);
|
|
670
|
+
this.#onRequestRender?.();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Concatenate the children's renders (like {@link Container}) while recording
|
|
675
|
+
* the line where the session list begins, so the fullscreen picker can hit-
|
|
676
|
+
* test mouse rows against the live list window. SessionList rebuilds its lines
|
|
677
|
+
* every frame, so Container's reference-memoization never applied here.
|
|
678
|
+
*
|
|
679
|
+
* In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
|
|
680
|
+
* to leave exactly enough room for the footer at the screen bottom, so the
|
|
681
|
+
* footer is always visible and never drifts as the list window resizes. The
|
|
682
|
+
* in-editor selector just appends the footer directly.
|
|
683
|
+
*/
|
|
684
|
+
render(width: number): readonly string[] {
|
|
685
|
+
const lines: string[] = [];
|
|
686
|
+
for (const child of this.children) {
|
|
687
|
+
const childLines = child.render(width);
|
|
688
|
+
if (child === this.#contentSlot) this.#listLineOffset = lines.length;
|
|
689
|
+
for (const line of childLines) lines.push(line);
|
|
690
|
+
}
|
|
691
|
+
const footer = this.#footerLines(width);
|
|
692
|
+
if (this.#fillHeight) {
|
|
693
|
+
const target = Math.max(0, this.#getTerminalRows() - footer.length);
|
|
694
|
+
if (lines.length > target) lines.length = target;
|
|
695
|
+
else for (let i = lines.length; i < target; i++) lines.push("");
|
|
696
|
+
}
|
|
697
|
+
this.#footerStart = lines.length;
|
|
698
|
+
for (const line of footer) lines.push(line);
|
|
699
|
+
return lines;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
|
|
703
|
+
#footerLines(width: number): string[] {
|
|
704
|
+
const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
|
|
705
|
+
const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
|
|
706
|
+
return ["", hint, "", ...this.#bottomBorder.render(width)];
|
|
616
707
|
}
|
|
617
708
|
|
|
618
709
|
handleInput(keyData: string): void {
|
|
710
|
+
if (keyData.startsWith("\x1b[<")) {
|
|
711
|
+
this.#handleMouse(keyData);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
619
714
|
if (this.#confirmationDialog) {
|
|
620
715
|
this.#confirmationDialog.handleInput(keyData);
|
|
621
716
|
} else {
|
|
@@ -623,6 +718,25 @@ export class SessionSelectorComponent extends Container {
|
|
|
623
718
|
}
|
|
624
719
|
}
|
|
625
720
|
|
|
721
|
+
/**
|
|
722
|
+
* SGR mouse reports, delivered only while the picker holds the alternate
|
|
723
|
+
* screen (the fullscreen overlay enables tracking and paints from screen row
|
|
724
|
+
* 0). Wheel scrolls the list; a left click resumes the session under the
|
|
725
|
+
* pointer. Mouse is inert while the delete-confirmation dialog is open.
|
|
726
|
+
*/
|
|
727
|
+
#handleMouse(data: string): void {
|
|
728
|
+
if (this.#confirmationDialog) return;
|
|
729
|
+
const event = parseSgrMouse(data);
|
|
730
|
+
if (!event) return;
|
|
731
|
+
if (event.wheel !== null) {
|
|
732
|
+
this.#sessionList.handleWheel(event.wheel);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (!event.leftClick || event.row >= this.#footerStart) return;
|
|
736
|
+
const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
|
|
737
|
+
if (index !== undefined) this.#sessionList.selectAndConfirm(index);
|
|
738
|
+
}
|
|
739
|
+
|
|
626
740
|
getSessionList(): SessionList {
|
|
627
741
|
return this.#sessionList;
|
|
628
742
|
}
|
|
@@ -703,7 +703,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
703
703
|
id: def.path,
|
|
704
704
|
label: def.label,
|
|
705
705
|
description: def.description,
|
|
706
|
-
currentValue: currentValue
|
|
706
|
+
currentValue: String(currentValue ?? ""),
|
|
707
707
|
values: [...def.values],
|
|
708
708
|
changed,
|
|
709
709
|
};
|
|
@@ -723,7 +723,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
723
723
|
id: def.path,
|
|
724
724
|
label: def.label,
|
|
725
725
|
description: def.description,
|
|
726
|
-
currentValue: (currentValue
|
|
726
|
+
currentValue: String(currentValue ?? ""),
|
|
727
727
|
submenu: (cv, done) => this.#createTextInput(def, cv, done),
|
|
728
728
|
changed,
|
|
729
729
|
};
|