@oh-my-pi/pi-tui 18.9.2 → 18.10.0
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 +29 -0
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/debug-server.d.ts +2 -0
- package/dist/types/overlays/ask-dialog.d.ts +2 -2
- package/dist/types/prompt/composer.d.ts +1 -20
- package/dist/types/tui.d.ts +10 -3
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/chrome/transcript-container.ts +202 -28
- package/src/debug-server.ts +51 -69
- package/src/hotkeys-markdown.ts +1 -0
- package/src/overlays/ask-dialog.ts +111 -31
- package/src/prompt/composer.ts +35 -72
- package/src/tui.ts +27 -4
package/src/debug-server.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createServer, type Server, type Socket } from "node:net";
|
|
1
|
+
import { type IpcConnection, IpcServer } from "@oh-my-pi/pi-natives";
|
|
3
2
|
import { type Component, isFocusable, type OverlayOptions, type TUI } from "./tui";
|
|
4
3
|
import { plainText } from "./native/spans";
|
|
5
4
|
|
|
@@ -209,72 +208,60 @@ function mouseSequence(x: number, y: number, action: string): string {
|
|
|
209
208
|
export class TuiDebugServer {
|
|
210
209
|
readonly #tui: TUI;
|
|
211
210
|
readonly #path: string;
|
|
212
|
-
#server:
|
|
213
|
-
#
|
|
211
|
+
#server: IpcServer | undefined;
|
|
212
|
+
#listening: Promise<boolean> | undefined;
|
|
213
|
+
#stopped = false;
|
|
214
|
+
#connections = new Set<IpcConnection>();
|
|
214
215
|
|
|
215
216
|
constructor(tui: TUI, path: string) {
|
|
216
217
|
this.#tui = tui;
|
|
217
218
|
this.#path = path;
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
/** Resolves true once the socket accepts connections, false if listen failed or the server stopped first. */
|
|
222
|
+
get ready(): Promise<boolean> {
|
|
223
|
+
return this.#listening ?? Promise.resolve(false);
|
|
224
|
+
}
|
|
225
|
+
|
|
220
226
|
start(): void {
|
|
221
|
-
if (this.#
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
} catch {
|
|
235
|
-
this.#server = undefined;
|
|
236
|
-
server.close();
|
|
237
|
-
}
|
|
227
|
+
if (this.#listening !== undefined) return;
|
|
228
|
+
this.#stopped = false;
|
|
229
|
+
this.#listening = IpcServer.listen(this.#path, { framing: "lines" }, connection => this.#accept(connection)).then(
|
|
230
|
+
server => {
|
|
231
|
+
if (this.#stopped) {
|
|
232
|
+
void server.close().catch(() => {});
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
this.#server = server;
|
|
236
|
+
return true;
|
|
237
|
+
},
|
|
238
|
+
() => false, // A live peer or unusable path disables the debug socket; the host application is unaffected.
|
|
239
|
+
);
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
stop(): void {
|
|
243
|
+
this.#stopped = true;
|
|
241
244
|
const server = this.#server;
|
|
242
245
|
this.#server = undefined;
|
|
243
|
-
|
|
244
|
-
this.#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
} catch {
|
|
248
|
-
// A failed or already-closed listener still needs its stale path removed.
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
if (existsSync(this.#path)) unlinkSync(this.#path);
|
|
252
|
-
} catch {
|
|
253
|
-
// Teardown must not affect the host application.
|
|
254
|
-
}
|
|
246
|
+
this.#listening = undefined;
|
|
247
|
+
for (const connection of this.#connections) connection.destroy();
|
|
248
|
+
this.#connections.clear();
|
|
249
|
+
void server?.close().catch(() => {});
|
|
255
250
|
}
|
|
256
251
|
|
|
257
|
-
#accept(
|
|
258
|
-
this.#
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (line.length > 0) this.#handleLine(socket, line);
|
|
268
|
-
newline = buffer.indexOf("\n");
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
socket.on("error", error => {
|
|
272
|
-
void error;
|
|
273
|
-
});
|
|
274
|
-
socket.on("close", () => this.#sockets.delete(socket));
|
|
252
|
+
#accept(connection: IpcConnection): void {
|
|
253
|
+
if (this.#stopped) {
|
|
254
|
+
connection.destroy();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.#connections.add(connection);
|
|
258
|
+
connection.start(
|
|
259
|
+
line => void this.#handleLine(connection, line),
|
|
260
|
+
() => this.#connections.delete(connection),
|
|
261
|
+
);
|
|
275
262
|
}
|
|
276
263
|
|
|
277
|
-
#handleLine(
|
|
264
|
+
async #handleLine(connection: IpcConnection, line: string): Promise<void> {
|
|
278
265
|
let request: DebugRequest;
|
|
279
266
|
try {
|
|
280
267
|
const parsed: unknown = JSON.parse(line);
|
|
@@ -282,36 +269,31 @@ export class TuiDebugServer {
|
|
|
282
269
|
throw new Error("request must be an object");
|
|
283
270
|
request = parsed as DebugRequest;
|
|
284
271
|
} catch (error) {
|
|
285
|
-
this.#write(
|
|
272
|
+
this.#write(connection, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
|
|
286
273
|
return;
|
|
287
274
|
}
|
|
275
|
+
let response: DebugResponse;
|
|
288
276
|
try {
|
|
289
|
-
|
|
290
|
-
if (request.op === "quit" && response.ok) {
|
|
291
|
-
this.#write(socket, response, () => {
|
|
292
|
-
this.#tui.stop();
|
|
293
|
-
process.exit(0);
|
|
294
|
-
});
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
this.#write(socket, response);
|
|
277
|
+
response = this.#dispatch(request);
|
|
298
278
|
} catch (error) {
|
|
299
|
-
|
|
279
|
+
response = { ok: false, error: errorMessage(error) };
|
|
280
|
+
}
|
|
281
|
+
this.#write(connection, response);
|
|
282
|
+
if (request.op === "quit" && response.ok) {
|
|
283
|
+
await connection.drained();
|
|
284
|
+
this.#tui.stop();
|
|
285
|
+
process.exit(0);
|
|
300
286
|
}
|
|
301
287
|
}
|
|
302
288
|
|
|
303
|
-
#write(
|
|
289
|
+
#write(connection: IpcConnection, response: DebugResponse): void {
|
|
304
290
|
let line: string;
|
|
305
291
|
try {
|
|
306
292
|
line = JSON.stringify(response);
|
|
307
293
|
} catch (error) {
|
|
308
294
|
line = JSON.stringify({ ok: false, error: errorMessage(error) });
|
|
309
295
|
}
|
|
310
|
-
|
|
311
|
-
socket.write(`${line}\n`, flushed);
|
|
312
|
-
} catch {
|
|
313
|
-
// Broken clients cannot affect the TUI.
|
|
314
|
-
}
|
|
296
|
+
connection.send(line);
|
|
315
297
|
}
|
|
316
298
|
|
|
317
299
|
#dispatch(request: DebugRequest): DebugResponse {
|
package/src/hotkeys-markdown.ts
CHANGED
|
@@ -135,6 +135,7 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
|
|
|
135
135
|
{ keys: [act("app.history.search")], action: "Search prompt history" },
|
|
136
136
|
{ keys: [act("app.tools.expand")], action: "Toggle tool output expansion" },
|
|
137
137
|
{ keys: [act("app.tools.toggleVisibility")], action: "Toggle tool activity visibility" },
|
|
138
|
+
{ keys: [act("app.ask.hide")], action: "Hide/show a pending ask question" },
|
|
138
139
|
{ keys: [act("app.thinking.toggle")], action: "Toggle thinking block visibility" },
|
|
139
140
|
{ keys: [act("app.editor.external")], action: "Edit message in external editor" },
|
|
140
141
|
{ keys: [act("app.retry")], action: "Retry last failed assistant turn" },
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
import { optionMarker } from "../tools/ask";
|
|
82
82
|
import { CountdownTimer } from "../chrome/countdown-timer";
|
|
83
83
|
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
84
|
-
import { formatKeyHint, formatKeyHints } from "../app-keybindings";
|
|
84
|
+
import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../app-keybindings";
|
|
85
85
|
import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
|
|
86
86
|
import { handleTabSwitchKey } from "../chrome/selector-helpers";
|
|
87
87
|
import { col, node, span, text } from "../native/describe";
|
|
@@ -524,7 +524,6 @@ export function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]
|
|
|
524
524
|
}
|
|
525
525
|
|
|
526
526
|
export class AskDialogComponent implements Component {
|
|
527
|
-
readonly retireDisplacedTranscript = true;
|
|
528
527
|
#states: QuestionState[];
|
|
529
528
|
#activeTabIndex = 0;
|
|
530
529
|
#submitScrollOffset = 0;
|
|
@@ -543,6 +542,8 @@ export class AskDialogComponent implements Component {
|
|
|
543
542
|
#contentWidth = 76;
|
|
544
543
|
#headerExpandable = false;
|
|
545
544
|
#descExpandable = false;
|
|
545
|
+
/** Collapsed to one line (`app.ask.hide` / the Hide button) while the question stays pending. */
|
|
546
|
+
#hidden = false;
|
|
546
547
|
/** Memoized native description; dropped whenever dialog state may have changed. */
|
|
547
548
|
#native: NativeNode | undefined;
|
|
548
549
|
/** Input-guard blocked state {@link #native} was described with (the guard flips outside this dialog). */
|
|
@@ -632,7 +633,7 @@ export class AskDialogComponent implements Component {
|
|
|
632
633
|
}
|
|
633
634
|
|
|
634
635
|
#canToggleExpansion(): boolean {
|
|
635
|
-
if (this.#closed || this.#isSubmitTab()) return false;
|
|
636
|
+
if (this.#closed || this.#hidden || this.#isSubmitTab()) return false;
|
|
636
637
|
const question = this.#questions[this.#currentQuestionIndex()];
|
|
637
638
|
if (!question) return false;
|
|
638
639
|
if (this.#expanded) return true;
|
|
@@ -652,6 +653,10 @@ export class AskDialogComponent implements Component {
|
|
|
652
653
|
this.#finishCancel();
|
|
653
654
|
return;
|
|
654
655
|
}
|
|
656
|
+
if (getKeybindings().matches(keyData, "app.ask.hide")) {
|
|
657
|
+
this.#setHidden(!this.#hidden);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
655
660
|
// Expand before the draft-input guard so Ctrl+O can reveal a truncated
|
|
656
661
|
// question even while a pending prompt still owns typing.
|
|
657
662
|
if (matchesAppToolsExpand(keyData)) {
|
|
@@ -664,6 +669,12 @@ export class AskDialogComponent implements Component {
|
|
|
664
669
|
this.#requestRender();
|
|
665
670
|
return;
|
|
666
671
|
}
|
|
672
|
+
if (this.#hidden) {
|
|
673
|
+
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
|
674
|
+
this.#setHidden(false);
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
667
678
|
if (this.#hasSubmitTab() && handleTabSwitchKey(keyData, direction => this.#switchTab(direction))) {
|
|
668
679
|
this.#requestRender();
|
|
669
680
|
return;
|
|
@@ -680,6 +691,7 @@ export class AskDialogComponent implements Component {
|
|
|
680
691
|
// renders as the next sibling in the same container, so this lands in the
|
|
681
692
|
// same frame).
|
|
682
693
|
this.options.inputGuard?.syncPresentation?.();
|
|
694
|
+
if (this.#hidden) return this.#renderHidden(width);
|
|
683
695
|
const innerWidth = Math.max(1, width - 4);
|
|
684
696
|
this.#contentWidth = innerWidth;
|
|
685
697
|
// Fixed panel height: measured from the tallest tab at spawn and
|
|
@@ -725,6 +737,11 @@ export class AskDialogComponent implements Component {
|
|
|
725
737
|
const ring = this.#countdown !== undefined && cx.supports("meter");
|
|
726
738
|
if (ring) this.#startRingTick();
|
|
727
739
|
if (this.#native && this.#nativeBlocked === blocked) return this.#native;
|
|
740
|
+
if (this.#hidden) {
|
|
741
|
+
this.#native = this.#describeHidden(ring);
|
|
742
|
+
this.#nativeBlocked = blocked;
|
|
743
|
+
return this.#native;
|
|
744
|
+
}
|
|
728
745
|
const children: NativeChild[] = [];
|
|
729
746
|
const head = this.#describeHead(ring);
|
|
730
747
|
if (head) children.push(head);
|
|
@@ -752,31 +769,52 @@ export class AskDialogComponent implements Component {
|
|
|
752
769
|
}
|
|
753
770
|
if (this.#countdown) {
|
|
754
771
|
children.push(node("spacer", { grow: 1 }));
|
|
755
|
-
|
|
756
|
-
const total = Math.max(1, this.options.timeout ?? 1);
|
|
757
|
-
const left = Math.max(0, this.#countdownDeadline - Date.now());
|
|
758
|
-
children.push(
|
|
759
|
-
node(
|
|
760
|
-
"meter",
|
|
761
|
-
{
|
|
762
|
-
value: Math.round((left / total) * 1000) / 1000,
|
|
763
|
-
style: "ring",
|
|
764
|
-
size: "sm",
|
|
765
|
-
label: `${Math.ceil(left / 1000)}s`,
|
|
766
|
-
title: "Answers the recommended option when the time runs out",
|
|
767
|
-
},
|
|
768
|
-
undefined,
|
|
769
|
-
"timer",
|
|
770
|
-
),
|
|
771
|
-
);
|
|
772
|
-
} else {
|
|
773
|
-
children.push(this.#countdown.describe());
|
|
774
|
-
}
|
|
772
|
+
children.push(this.#describeTimer(this.#countdown, ring));
|
|
775
773
|
}
|
|
776
774
|
if (children.length === 0) return undefined;
|
|
777
775
|
return node("row", { role: "omp.ask.head", gap: "sm", align: "center" }, children, "head");
|
|
778
776
|
}
|
|
779
777
|
|
|
778
|
+
/** The countdown: a ring when the terminal draws meters, else a clocked `elapsed`. */
|
|
779
|
+
#describeTimer(countdown: CountdownTimer, ring: boolean): NativeNode {
|
|
780
|
+
if (!ring) return countdown.describe();
|
|
781
|
+
const total = Math.max(1, this.options.timeout ?? 1);
|
|
782
|
+
const left = Math.max(0, this.#countdownDeadline - Date.now());
|
|
783
|
+
return node(
|
|
784
|
+
"meter",
|
|
785
|
+
{
|
|
786
|
+
value: Math.round((left / total) * 1000) / 1000,
|
|
787
|
+
style: "ring",
|
|
788
|
+
size: "sm",
|
|
789
|
+
label: `${Math.ceil(left / 1000)}s`,
|
|
790
|
+
title: "Answers the recommended option when the time runs out",
|
|
791
|
+
},
|
|
792
|
+
undefined,
|
|
793
|
+
"timer",
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Hidden: one action row in the composer's place — the question clipped to a line, the countdown, Show and Skip. */
|
|
798
|
+
#describeHidden(ring: boolean): NativeNode {
|
|
799
|
+
const question = this.#questions[this.#currentQuestionIndex()];
|
|
800
|
+
const more = this.#questions.length > 1 ? ` (+${this.#questions.length - 1} more)` : "";
|
|
801
|
+
const summary = text(
|
|
802
|
+
[
|
|
803
|
+
span("Ask hidden · ", "muted"),
|
|
804
|
+
span(`${normalizedInlineInput(sanitizeCarriageReturns(question?.question ?? ""))}${more}`),
|
|
805
|
+
],
|
|
806
|
+
{ truncate: "end" },
|
|
807
|
+
);
|
|
808
|
+
const hideKey = getKeybindings().getKeys("app.ask.hide")[0];
|
|
809
|
+
const show = actionButton("Show", "show", { tone: "accent", ...(hideKey ? { keys: hideKey } : {}) });
|
|
810
|
+
const cancelKey = getKeybindings().getKeys("tui.select.cancel")[0];
|
|
811
|
+
const skip = actionButton("Skip", "cancel", cancelKey ? { keys: cancelKey } : {});
|
|
812
|
+
const items: (NativeNode | null)[] = [summary, null];
|
|
813
|
+
if (this.#countdown) items.push(this.#describeTimer(this.#countdown, ring));
|
|
814
|
+
items.push(show, skip);
|
|
815
|
+
return col([actionBar(items)], { role: "omp.editor", gap: "md" });
|
|
816
|
+
}
|
|
817
|
+
|
|
780
818
|
/** Question tab: the question as markdown and its answers as radio/check rows (label, description, badge). */
|
|
781
819
|
#describeQuestionBody(children: NativeChild[]): void {
|
|
782
820
|
const active = this.#activeQuestionState();
|
|
@@ -870,14 +908,20 @@ export class AskDialogComponent implements Component {
|
|
|
870
908
|
/**
|
|
871
909
|
* The action bar: Submit ⏎ (Enter's action on this tab; `Next` while more
|
|
872
910
|
* questions follow), Note n, Skip esc. Keyboard-only keys live in the
|
|
873
|
-
* buttons' tooltips; a draft that owns input shows its hint instead.
|
|
911
|
+
* buttons' tooltips; a draft that owns input shows its hint instead. Hide
|
|
912
|
+
* (left-aligned, apart from the answer actions) collapses the dialog.
|
|
874
913
|
*/
|
|
875
914
|
#describeActions(blocked: boolean): NativeNode {
|
|
876
915
|
const cancelKey = getKeybindings().getKeys("tui.select.cancel")[0];
|
|
877
916
|
const skip = actionButton("Skip", "cancel", cancelKey ? { keys: cancelKey } : {});
|
|
917
|
+
const hideKey = getKeybindings().getKeys("app.ask.hide")[0];
|
|
918
|
+
const hide = actionButton("Hide", "hide", {
|
|
919
|
+
...(hideKey ? { keys: hideKey } : {}),
|
|
920
|
+
title: `Hide the question until you are ready to answer${hideKey ? ` ${formatTooltipKey(hideKey)}` : ""}`,
|
|
921
|
+
});
|
|
878
922
|
const inputGuard = this.options.inputGuard;
|
|
879
923
|
if (blocked && inputGuard) {
|
|
880
|
-
return actionBar([text([span(plainText(inputGuard.hint), "muted")], { truncate: "end" }), null, skip]);
|
|
924
|
+
return actionBar([text([span(plainText(inputGuard.hint), "muted")], { truncate: "end" }), null, hide, skip]);
|
|
881
925
|
}
|
|
882
926
|
const moves = ["up/down move"];
|
|
883
927
|
if (this.#hasSubmitTab()) moves.push("tab switches question");
|
|
@@ -887,7 +931,7 @@ export class AskDialogComponent implements Component {
|
|
|
887
931
|
tone: "accent",
|
|
888
932
|
title: "Submit answers enter",
|
|
889
933
|
});
|
|
890
|
-
return actionBar([null, skip, submit]);
|
|
934
|
+
return actionBar([hide, null, skip, submit]);
|
|
891
935
|
}
|
|
892
936
|
const question = this.#questions[this.#currentQuestionIndex()];
|
|
893
937
|
const last = this.#questions.length === 1;
|
|
@@ -899,14 +943,15 @@ export class AskDialogComponent implements Component {
|
|
|
899
943
|
title: `${enterLabel} enter · ${tips.join(" · ")}`,
|
|
900
944
|
});
|
|
901
945
|
const note = actionButton("Note", "note", { keys: "n", title: `Add a note to the highlighted answer n` });
|
|
902
|
-
return actionBar([null, note, skip, submit]);
|
|
946
|
+
return actionBar([hide, null, note, skip, submit]);
|
|
903
947
|
}
|
|
904
948
|
|
|
905
949
|
/**
|
|
906
950
|
* Pointer actions: a tab click switches to it; a click on an answer row is
|
|
907
951
|
* Enter on it (Space for a multi-select option, so it toggles; its
|
|
908
952
|
* double-click `activate` is Enter); the action bar's Submit, Note and Skip
|
|
909
|
-
* run exactly what Enter, `n` and Esc run on the highlighted row
|
|
953
|
+
* run exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
|
|
954
|
+
* collapse and restore the dialog.
|
|
910
955
|
*/
|
|
911
956
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
912
957
|
if (this.#closed || this.#promptActive) return;
|
|
@@ -915,13 +960,17 @@ export class AskDialogComponent implements Component {
|
|
|
915
960
|
this.#finishCancel();
|
|
916
961
|
return;
|
|
917
962
|
}
|
|
918
|
-
if (
|
|
963
|
+
if (event.act === "hide" || event.act === "show") {
|
|
964
|
+
this.#setHidden(event.act === "hide");
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (this.#hidden || this.options.inputGuard?.isBlocked()) return;
|
|
919
968
|
if (event.act === "submit") this.#pressEnter();
|
|
920
969
|
else if (event.act === "note") this.#pressNote();
|
|
921
970
|
return;
|
|
922
971
|
}
|
|
923
972
|
if (event.type !== "select" && event.type !== "activate") return;
|
|
924
|
-
if (this.options.inputGuard?.isBlocked()) return;
|
|
973
|
+
if (this.#hidden || this.options.inputGuard?.isBlocked()) return;
|
|
925
974
|
const key = leafKey(event.key);
|
|
926
975
|
if (key === "tabs") {
|
|
927
976
|
if (!this.#hasSubmitTab()) return;
|
|
@@ -1049,6 +1098,35 @@ export class AskDialogComponent implements Component {
|
|
|
1049
1098
|
return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`;
|
|
1050
1099
|
}
|
|
1051
1100
|
|
|
1101
|
+
#setHidden(hidden: boolean): void {
|
|
1102
|
+
if (this.#hidden === hidden) return;
|
|
1103
|
+
this.#hidden = hidden;
|
|
1104
|
+
this.#countdown?.reset();
|
|
1105
|
+
this.#requestRender();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Hidden: one line in the composer's place — title (with countdown), the question clipped to fit, and how to bring it back. */
|
|
1109
|
+
#renderHidden(width: number): readonly string[] {
|
|
1110
|
+
const hideKey = editorKey("app.ask.hide");
|
|
1111
|
+
const enter = formatKeyHint("enter");
|
|
1112
|
+
const inputGuard = this.options.inputGuard;
|
|
1113
|
+
const blocked = inputGuard?.isBlocked() ?? false;
|
|
1114
|
+
// While a draft owns input, Enter goes to the draft, so only the hide key restores.
|
|
1115
|
+
const show = blocked ? hideKey : hideKey ? `${hideKey}/${enter}` : enter;
|
|
1116
|
+
const hints: string[] = [];
|
|
1117
|
+
if (blocked && inputGuard) hints.push(inputGuard.hint);
|
|
1118
|
+
if (show) hints.push(`${show} show`);
|
|
1119
|
+
hints.push(`${editorKey("tui.select.cancel")} cancel`);
|
|
1120
|
+
const hint = theme.fg("dim", ` · ${hints.join(" · ")}`);
|
|
1121
|
+
const label = theme.fg("accent", `${this.#titleText()} hidden:`);
|
|
1122
|
+
const question = this.#questions[this.#currentQuestionIndex()];
|
|
1123
|
+
const more = this.#questions.length > 1 ? theme.fg("dim", ` (+${this.#questions.length - 1} more)`) : "";
|
|
1124
|
+
const summary = normalizedInlineInput(sanitizeCarriageReturns(question?.question ?? ""));
|
|
1125
|
+
const summaryWidth = Math.max(1, width - visibleWidth(label) - visibleWidth(more) - visibleWidth(hint) - 1);
|
|
1126
|
+
const clipped = truncateToWidth(summary, summaryWidth, Ellipsis.Unicode);
|
|
1127
|
+
return [truncateToWidth(`${label} ${theme.fg("muted", clipped)}${more}${hint}`, width, Ellipsis.Unicode)];
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1052
1130
|
#hasSubmitTab(): boolean {
|
|
1053
1131
|
// Multi questions confirm on the Submit tab (Enter toggles, never
|
|
1054
1132
|
// submits), so any multi question forces the tab even when there is
|
|
@@ -1114,7 +1192,9 @@ export class AskDialogComponent implements Component {
|
|
|
1114
1192
|
}
|
|
1115
1193
|
|
|
1116
1194
|
#footerHintText(indicator: string): string {
|
|
1117
|
-
|
|
1195
|
+
// Hide rides along with cancel: both leave the question view without answering.
|
|
1196
|
+
const hideKey = editorKey("app.ask.hide");
|
|
1197
|
+
const cancel = `${editorKey("tui.select.cancel")} cancel${hideKey ? ` · ${hideKey} hide` : ""}`;
|
|
1118
1198
|
const enter = formatKeyHint("enter");
|
|
1119
1199
|
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
1120
1200
|
const inputGuard = this.options.inputGuard;
|
package/src/prompt/composer.ts
CHANGED
|
@@ -86,18 +86,8 @@ export interface ComposerOptions {
|
|
|
86
86
|
readonly now?: () => number;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/** How {@link Composer.setRuntimeChildren}
|
|
89
|
+
/** How {@link Composer.setRuntimeChildren} lays out the roots below the transcript. */
|
|
90
90
|
export interface RuntimeChildrenOptions {
|
|
91
|
-
/**
|
|
92
|
-
* Below-transcript roots whose growth is momentary (inline dialogs, a tall
|
|
93
|
-
* multi-line editor). They are billed at their smallest height so expansion
|
|
94
|
-
* clips the live tail instead of retiring rows a later shrink could not
|
|
95
|
-
* reclaim (#11007). Every other root is billed at its current height, so
|
|
96
|
-
* settled rows it displaces retire to native scrollback. Decision panels
|
|
97
|
-
* declaring `retireDisplacedTranscript` opt out of this floor even when
|
|
98
|
-
* mounted as a child of a transient editor container.
|
|
99
|
-
*/
|
|
100
|
-
readonly transient?: readonly Component[];
|
|
101
91
|
/**
|
|
102
92
|
* Dock order on a TSP terminal (no status strip; the composer carries its
|
|
103
93
|
* facts): the native dock stacks HUD pills, the working row and queued messages over
|
|
@@ -257,19 +247,17 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
257
247
|
#retiredHeaderStart = 0;
|
|
258
248
|
#resizeRetiredHeaderStart: number | undefined;
|
|
259
249
|
#lastNormalRows = 0;
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// The floor is terminal-height independent —
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
#
|
|
271
|
-
#transientChromeFloor: number | undefined;
|
|
272
|
-
#anchorAfterInlineRetirement = false;
|
|
250
|
+
// The smallest height the chrome below the transcript has rendered at since
|
|
251
|
+
// mount. Retirement bills that chrome at this floor, never its current
|
|
252
|
+
// height: a dialog, report, HUD or tall draft that grows clips the live tail
|
|
253
|
+
// instead of committing rows it displaces, so when it shrinks those rows
|
|
254
|
+
// refill the screen rather than leaving a band no frame can fill (#11007).
|
|
255
|
+
// Displaced rows reach scrollback once newer output pushes them past the
|
|
256
|
+
// floor, or when the chrome shrinks back and they scroll off normally.
|
|
257
|
+
// The floor is terminal-height independent — chrome heights do not scale
|
|
258
|
+
// with rows — so it is retained across resizes rather than rediscovered
|
|
259
|
+
// from whatever chrome is expanded when the height changes.
|
|
260
|
+
#chromeFloor: number | undefined;
|
|
273
261
|
/** Rows the chrome below each below-transcript root took in the last frame (see {@link rowsBelow}). */
|
|
274
262
|
#rowsBelow = new Map<Component, number>();
|
|
275
263
|
#lastInterruptAt = 0;
|
|
@@ -349,18 +337,6 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
349
337
|
return this.#rowsBelow.get(root);
|
|
350
338
|
}
|
|
351
339
|
|
|
352
|
-
/**
|
|
353
|
-
* Keep the input on the bottom row while the live rows cannot fill the
|
|
354
|
-
* screen, as after an inline decision panel closes. A tall block that just
|
|
355
|
-
* left the chrome above the editor (a command report) may have scrolled
|
|
356
|
-
* rows into native history that cannot be pulled back; without the pin the
|
|
357
|
-
* editor would jump up to where the shorter frame now ends. The pin lifts
|
|
358
|
-
* once live rows fill the screen again.
|
|
359
|
-
*/
|
|
360
|
-
pinInputToBottom(): void {
|
|
361
|
-
this.#anchorAfterInlineRetirement = true;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
340
|
/** Compose the bounded mutable viewport and the next ordered history append. */
|
|
365
341
|
renderFrame(viewport: ViewportSize): TerminalFramePlan {
|
|
366
342
|
if (!this.#started || this.#stopped) return { viewport: [] };
|
|
@@ -384,55 +360,32 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
384
360
|
const afterRoots = roots.slice(transcriptIndex + 1);
|
|
385
361
|
const after: string[] = [];
|
|
386
362
|
const afterSpans: ViewportClickSpan[] = [];
|
|
387
|
-
let transientRows = 0;
|
|
388
|
-
let displacingRows = 0;
|
|
389
|
-
let decisionPanelOpen = false;
|
|
390
363
|
const ends: { root: Component; end: number }[] = [];
|
|
391
364
|
for (const root of afterRoots) {
|
|
392
|
-
const chrome: Component = root;
|
|
393
|
-
const start = after.length;
|
|
394
365
|
this.#renderBelowRoot(root, width, after, afterSpans);
|
|
395
366
|
ends.push({ root, end: after.length });
|
|
396
|
-
if (this.#transientChrome.has(root)) transientRows += after.length - start;
|
|
397
|
-
if (
|
|
398
|
-
chrome.retireDisplacedTranscript ||
|
|
399
|
-
(root instanceof Container && root.children.some(child => child.retireDisplacedTranscript))
|
|
400
|
-
) {
|
|
401
|
-
if (this.#transientChrome.has(root)) displacingRows += after.length - start;
|
|
402
|
-
decisionPanelOpen = true;
|
|
403
|
-
}
|
|
404
367
|
}
|
|
405
368
|
this.#rowsBelow = new Map(ends.map(({ root, end }) => [root, after.length - end]));
|
|
406
369
|
// Offer history under capacity pressure only: blocks stay live (and keep
|
|
407
370
|
// reflowing to the current width) while the screen has room. A batch
|
|
408
371
|
// leaves the mutable viewport in the same frame it is appended, so its
|
|
409
372
|
// rows are never painted twice.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// peak: a confirmation dialog or a tall multi-line editor clips the live tail for
|
|
413
|
-
// its lifetime, but must not permanently commit transcript rows to native
|
|
414
|
-
// history — otherwise a later shrink cannot refill the freed rows and the
|
|
415
|
-
// editor drifts up above a band of blank rows (#11007).
|
|
416
|
-
this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
|
|
417
|
-
// An ask panel remains open while the user reads the preceding response.
|
|
418
|
-
// Its displaced settled rows must be reachable in scrollback now, rather
|
|
419
|
-
// than clipped until the panel closes. Ordinary drafts retain their floor.
|
|
420
|
-
const belowFloor = after.length - transientRows + Math.max(this.#transientChromeFloor, displacingRows);
|
|
373
|
+
this.#chromeFloor = Math.min(this.#chromeFloor ?? after.length, after.length);
|
|
374
|
+
const belowFloor = this.#chromeFloor;
|
|
421
375
|
const now = performance.now();
|
|
422
376
|
const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
|
|
423
377
|
// Retirement measures the same live blocks the viewport lays out below;
|
|
424
378
|
// one open frame renders each of them once for both.
|
|
425
379
|
transcript.beginFrame(frame);
|
|
426
380
|
const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
|
|
427
|
-
if (decisionPanelOpen && history !== undefined) this.#anchorAfterInlineRetirement = true;
|
|
428
381
|
const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
|
|
429
382
|
const headerRows = headerVisible ? this.#header.render(width) : [];
|
|
430
383
|
const before = [...headerRows, ...preRoots];
|
|
431
384
|
// The live tail is laid out against the same baseline retirement is
|
|
432
385
|
// billed against, so its compaction allocator (one row per block, no
|
|
433
386
|
// inter-block blanks) engages only when a block genuinely cannot retire.
|
|
434
|
-
// Rows the
|
|
435
|
-
// the `drop` slice below
|
|
387
|
+
// Rows the chrome above its floor displaces are clipped from the top by
|
|
388
|
+
// the `drop` slice below; they stay live until the chrome shrinks.
|
|
436
389
|
const active = transcript.renderViewport(width, Math.max(0, rows - before.length - belowFloor), frame);
|
|
437
390
|
const activeSpans: ViewportClickSpan[] = [];
|
|
438
391
|
for (const span of transcript.getLastViewportSpans()) {
|
|
@@ -442,12 +395,24 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
442
395
|
}
|
|
443
396
|
const drop = Math.max(0, before.length + active.length + after.length - rows);
|
|
444
397
|
const mutable = [...before, ...active, ...after].slice(drop);
|
|
445
|
-
// Once
|
|
446
|
-
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
398
|
+
// Once a paint reaches the bottom row over committed history, rows above
|
|
399
|
+
// it may already be in native scrollback, which no later frame can pull
|
|
400
|
+
// back down. Keep the input on the bottom row instead of stranding a
|
|
401
|
+
// band of blank rows under it whenever the frame shrinks — a closing
|
|
402
|
+
// dialog, report or HUD, or a live block collapsing. The band sits
|
|
403
|
+
// between the history still on screen and the live rows; replaying
|
|
404
|
+
// those rows would duplicate them, clearing history would lose them.
|
|
405
|
+
// Replays position themselves.
|
|
406
|
+
// Without native history the live viewport is the whole document and
|
|
407
|
+
// shrinks in place.
|
|
408
|
+
const filledHistoryRows =
|
|
409
|
+
history?.kind === "replay" || !(history !== undefined || this.ui.hasProviderHistory())
|
|
410
|
+
? undefined
|
|
411
|
+
: this.ui.getFilledHistoryRows();
|
|
412
|
+
const topPadding =
|
|
413
|
+
filledHistoryRows === undefined
|
|
414
|
+
? 0
|
|
415
|
+
: Math.max(0, rows - Math.min(rows, filledHistoryRows + (history?.rows.length ?? 0)) - mutable.length);
|
|
451
416
|
if (topPadding > 0) mutable.unshift(...Array<string>(topPadding).fill(""));
|
|
452
417
|
const viewportLength = mutable.length;
|
|
453
418
|
const spans: ViewportClickSpan[] = [];
|
|
@@ -924,9 +889,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
924
889
|
/** Mount or replace session-aware root children while preserving the header and status hosts. */
|
|
925
890
|
setRuntimeChildren(children: readonly Component[], options: RuntimeChildrenOptions = {}): void {
|
|
926
891
|
if (this.#stopped) return;
|
|
927
|
-
this.#
|
|
928
|
-
this.#transientChromeFloor = undefined;
|
|
929
|
-
this.#anchorAfterInlineRetirement = false;
|
|
892
|
+
this.#chromeFloor = undefined;
|
|
930
893
|
this.#nativeDock = options.nativeDock;
|
|
931
894
|
this.ui.removeChild(this.#statusHost);
|
|
932
895
|
if (this.#runtimeMounted) {
|