@oh-my-pi/pi-tui 18.4.4 → 18.4.6
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 +46 -0
- package/dist/types/apps/git/sidebar.d.ts +2 -2
- package/dist/types/chat/recap-notice.d.ts +13 -0
- package/dist/types/components/editor.d.ts +15 -3
- package/dist/types/components/input.d.ts +11 -1
- package/dist/types/native/blobs.d.ts +1 -1
- package/dist/types/native/node.d.ts +63 -1
- package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
- package/dist/types/overlays/ask-dialog.d.ts +9 -5
- package/dist/types/overlays/copy-selector.d.ts +0 -23
- package/dist/types/overlays/error-banner.d.ts +5 -2
- package/dist/types/overlays/hook-editor.d.ts +12 -0
- package/dist/types/overlays/jobs-panel.d.ts +25 -1
- package/dist/types/overlays/model-hub.d.ts +2 -0
- package/dist/types/overlays/rewind-selector.d.ts +17 -4
- package/dist/types/prompt/custom-editor.d.ts +16 -8
- package/dist/types/prompt/welcome.d.ts +3 -3
- package/dist/types/render/render-utils.d.ts +5 -0
- package/dist/types/status-line/component.d.ts +2 -0
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/status-line/metrics.d.ts +4 -0
- package/dist/types/status-line/types.d.ts +13 -2
- package/dist/types/tools/edit.d.ts +2 -0
- package/dist/types/tools/task.d.ts +7 -0
- package/dist/types/tui.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/git/sidebar.ts +15 -2
- package/src/chat/assistant-message.ts +2 -2
- package/src/chat/recap-notice.ts +30 -0
- package/src/chat/tool-execution.ts +19 -3
- package/src/chat/user-message.ts +2 -1
- package/src/components/editor.ts +115 -16
- package/src/components/input.ts +57 -1
- package/src/native/backend.ts +28 -10
- package/src/native/blobs.ts +1 -1
- package/src/native/encode.ts +1 -0
- package/src/native/node.ts +108 -1
- package/src/native/reconcile.ts +5 -4
- package/src/overlays/agent-hub-projection.ts +25 -0
- package/src/overlays/agent-hub.ts +4 -15
- package/src/overlays/ask-dialog.ts +20 -32
- package/src/overlays/copy-selector.ts +6 -6
- package/src/overlays/error-banner.ts +14 -3
- package/src/overlays/hook-editor.ts +59 -6
- package/src/overlays/jobs-panel.ts +132 -49
- package/src/overlays/model-browser.ts +56 -12
- package/src/overlays/model-hub.ts +82 -29
- package/src/overlays/rewind-selector.ts +235 -332
- package/src/overlays/session-selector.ts +19 -20
- package/src/prompt/custom-editor.ts +119 -30
- package/src/prompt/welcome.ts +4 -33
- package/src/render/render-utils.ts +51 -3
- package/src/status-line/component.ts +61 -30
- package/src/status-line/host.ts +2 -0
- package/src/status-line/metrics.ts +36 -5
- package/src/status-line/segments.ts +9 -1
- package/src/status-line/types.ts +13 -2
- package/src/terminal.ts +27 -6
- package/src/tools/edit.ts +23 -0
- package/src/tools/eval.ts +43 -11
- package/src/tools/grouped-file-output.ts +6 -0
- package/src/tools/task.ts +29 -4
- package/src/tui.ts +9 -1
package/src/native/node.ts
CHANGED
|
@@ -28,6 +28,11 @@ export type NativeNode = {
|
|
|
28
28
|
* or reordered so their ids and terminal-side view state survive.
|
|
29
29
|
*/
|
|
30
30
|
readonly key?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Scroll the node into view, placed like the `reveal` op, when it is
|
|
33
|
+
* added. Key a node by what it points at to reveal it again on a move.
|
|
34
|
+
*/
|
|
35
|
+
readonly reveal?: "start" | "end" | "nearest";
|
|
31
36
|
};
|
|
32
37
|
}[TspKind];
|
|
33
38
|
|
|
@@ -60,6 +65,15 @@ export interface NativeSurface {
|
|
|
60
65
|
readonly dock: readonly NativeChild[];
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The screen surface a fullscreen overlay fills as a page of its own (see
|
|
70
|
+
* `Component.describeScreen`): its regions plus the surface `role`, which the
|
|
71
|
+
* terminal exposes to styling.
|
|
72
|
+
*/
|
|
73
|
+
export interface NativeScreen extends NativeSurface {
|
|
74
|
+
readonly role: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
/**
|
|
64
78
|
* A user action on a node a component described, routed back to that
|
|
65
79
|
* component. `key` is the node's key path inside the component (`""` for the
|
|
@@ -83,7 +97,100 @@ export type NativeUiEvent =
|
|
|
83
97
|
readonly key: string;
|
|
84
98
|
readonly item: string;
|
|
85
99
|
readonly value: boolean | number | string | readonly string[] | null;
|
|
86
|
-
}
|
|
100
|
+
}
|
|
101
|
+
/** An edit over the terminal's own selection in an `editor`/`input` node (see {@link NativeTextEdit}). */
|
|
102
|
+
| ({ readonly type: "edit"; readonly key: string } & NativeTextEdit);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A primitive edit the terminal made over its own text selection (cut,
|
|
106
|
+
* typing, Backspace/Delete or paste over it, or collapsing it). Offsets are
|
|
107
|
+
* UTF-16 code units into the text last described (lines joined with `"\n"`),
|
|
108
|
+
* the coordinates of the node's `cursor`: replace `[from, to)` with `text`,
|
|
109
|
+
* then put the caret at `cursor` in the resulting text. `from == to` with an
|
|
110
|
+
* empty `text` is a pure caret move. `len` is the length of the text the
|
|
111
|
+
* terminal saw: when the current text differs (keys in flight changed it) the
|
|
112
|
+
* edit is stale and ignored.
|
|
113
|
+
*/
|
|
114
|
+
export interface NativeTextEdit {
|
|
115
|
+
readonly from: number;
|
|
116
|
+
readonly to: number;
|
|
117
|
+
readonly text: string;
|
|
118
|
+
readonly cursor: number;
|
|
119
|
+
readonly len: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A {@link NativeTextEdit} checked against the current text and clamped, ready to apply. */
|
|
123
|
+
export interface ResolvedTextEdit {
|
|
124
|
+
/** Replaced range in the current text, widened by the caller's `widen`. */
|
|
125
|
+
readonly from: number;
|
|
126
|
+
readonly to: number;
|
|
127
|
+
/** The cleaned replacement. */
|
|
128
|
+
readonly insert: string;
|
|
129
|
+
/** The whole text after the edit. */
|
|
130
|
+
readonly text: string;
|
|
131
|
+
/** Caret in {@link text}. */
|
|
132
|
+
readonly cursor: number;
|
|
133
|
+
/** Whether the text changes (false: a pure caret move). */
|
|
134
|
+
readonly changed: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `value` as an integer offset in `[0, max]` (anything non-finite is 0). */
|
|
138
|
+
export function clampTextOffset(value: number, max: number): number {
|
|
139
|
+
return Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 0), max) : 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether `offset` falls between the halves of a surrogate pair in `text`. */
|
|
143
|
+
function splitsSurrogatePair(text: string, offset: number): boolean {
|
|
144
|
+
if (offset <= 0 || offset >= text.length) return false;
|
|
145
|
+
const high = text.charCodeAt(offset - 1);
|
|
146
|
+
const low = text.charCodeAt(offset);
|
|
147
|
+
return high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check `edit` against `current` and resolve it: null when stale (`len`
|
|
152
|
+
* differs), else the range clamped (reversed ends swapped, never splitting a
|
|
153
|
+
* surrogate pair), widened by `widen` when non-empty, the replacement passed
|
|
154
|
+
* through `clean`, and the caret mapped from the text the terminal expects
|
|
155
|
+
* onto the text the edit actually produces.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveTextEdit(
|
|
158
|
+
current: string,
|
|
159
|
+
edit: NativeTextEdit,
|
|
160
|
+
clean: (text: string) => string,
|
|
161
|
+
widen?: (from: number, to: number) => { from: number; to: number },
|
|
162
|
+
): ResolvedTextEdit | null {
|
|
163
|
+
if (edit.len !== current.length) return null;
|
|
164
|
+
const size = current.length;
|
|
165
|
+
let from = clampTextOffset(edit.from, size);
|
|
166
|
+
let to = clampTextOffset(edit.to, size);
|
|
167
|
+
if (to < from) [from, to] = [to, from];
|
|
168
|
+
if (splitsSurrogatePair(current, from)) from--;
|
|
169
|
+
if (splitsSurrogatePair(current, to)) to++;
|
|
170
|
+
const raw = typeof edit.text === "string" ? edit.text : "";
|
|
171
|
+
const widened = widen && from < to ? widen(from, to) : { from, to };
|
|
172
|
+
const insert = raw ? clean(raw) : "";
|
|
173
|
+
const text = current.slice(0, widened.from) + insert + current.slice(widened.to);
|
|
174
|
+
// Where the terminal's caret sits in its own result (`[from, to)` replaced
|
|
175
|
+
// by `raw`), carried over to ours: widening swallows neighbours, cleaning
|
|
176
|
+
// may change the replacement's length.
|
|
177
|
+
const expected = clampTextOffset(edit.cursor, size - (to - from) + raw.length);
|
|
178
|
+
let cursor: number;
|
|
179
|
+
if (expected <= widened.from) cursor = expected;
|
|
180
|
+
else if (expected <= from) cursor = widened.from;
|
|
181
|
+
else if (expected < from + raw.length) cursor = widened.from + Math.min(expected - from, insert.length);
|
|
182
|
+
else cursor = widened.from + insert.length + Math.max(0, expected - from - raw.length - (widened.to - to));
|
|
183
|
+
cursor = Math.min(cursor, text.length);
|
|
184
|
+
if (splitsSurrogatePair(text, cursor)) cursor--;
|
|
185
|
+
return {
|
|
186
|
+
from: widened.from,
|
|
187
|
+
to: widened.to,
|
|
188
|
+
insert,
|
|
189
|
+
text,
|
|
190
|
+
cursor,
|
|
191
|
+
changed: widened.from !== widened.to || insert !== "",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
87
194
|
|
|
88
195
|
/** The expanded state a `toggle` on the component's root node asks for, else undefined. */
|
|
89
196
|
export function rootToggleExpanded(event: NativeUiEvent): boolean | undefined {
|
package/src/native/reconcile.ts
CHANGED
|
@@ -211,8 +211,8 @@ export class Reconciler {
|
|
|
211
211
|
#ops: TspOp[] = [];
|
|
212
212
|
#dels: Entry[] = [];
|
|
213
213
|
#settles: string[] = [];
|
|
214
|
-
/** Selected list items sent this frame, revealed once the frame's adds have landed. */
|
|
215
|
-
#reveals: string[] = [];
|
|
214
|
+
/** Selected list items and added `reveal` nodes sent this frame, revealed once the frame's adds have landed. */
|
|
215
|
+
#reveals: [id: string, at: "start" | "end" | "nearest"][] = [];
|
|
216
216
|
/** Wire ids `list.selected` keys resolved to (an unresolved key names no node to reveal). */
|
|
217
217
|
#selectedIds = new Set<string>();
|
|
218
218
|
#moves: PendingMove[] = [];
|
|
@@ -292,7 +292,7 @@ export class Reconciler {
|
|
|
292
292
|
}
|
|
293
293
|
this.#parked = [];
|
|
294
294
|
for (const id of this.#settles) this.#ops.push(["settle", id]);
|
|
295
|
-
for (const id of this.#reveals) this.#ops.push(["reveal", id,
|
|
295
|
+
for (const [id, at] of this.#reveals) this.#ops.push(["reveal", id, at]);
|
|
296
296
|
const ops = this.#ops;
|
|
297
297
|
this.#ops = [];
|
|
298
298
|
return this.#gone.size === 0 ? ops : ops.filter(op => !this.#touchesGone(op));
|
|
@@ -716,6 +716,7 @@ export class Reconciler {
|
|
|
716
716
|
|
|
717
717
|
/** A full wire subtree for an entry; nested components get states (or pending moves when mounted elsewhere). */
|
|
718
718
|
#materialize(entry: NodeEntry, walk: Walk): TspNode {
|
|
719
|
+
if (entry.node.reveal) this.#reveals.push([entry.id, entry.node.reveal]);
|
|
719
720
|
const entries = this.#entries(entry.node.c, entry.keypath, entry.owner, entry.hoist, walk);
|
|
720
721
|
const children: TspNode[] = [];
|
|
721
722
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -779,7 +780,7 @@ export class Reconciler {
|
|
|
779
780
|
|
|
780
781
|
/** A list's selection went out (on add or change): scroll it into view at frame end. */
|
|
781
782
|
#noteSelected(selected: unknown): void {
|
|
782
|
-
if (typeof selected === "string" && this.#selectedIds.has(selected)) this.#reveals.push(selected);
|
|
783
|
+
if (typeof selected === "string" && this.#selectedIds.has(selected)) this.#reveals.push([selected, "nearest"]);
|
|
783
784
|
}
|
|
784
785
|
|
|
785
786
|
/** Move components an `add` left out into their new parent, last first so every `before` exists. */
|
|
@@ -110,6 +110,31 @@ function readSessionMetrics(session: NonNullable<AgentRecordLike["session"]>): A
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** Live session whose own messages back a row's metrics when no observer progress exists. */
|
|
114
|
+
export function hubFallbackStatsSession<TRecord extends AgentRecordLike>(
|
|
115
|
+
ref: TRecord,
|
|
116
|
+
observed: ObservableSession | undefined,
|
|
117
|
+
): NonNullable<TRecord["session"]> | undefined {
|
|
118
|
+
if (observed?.progress) return undefined;
|
|
119
|
+
const session = ref.session;
|
|
120
|
+
return session && typeof session.getSessionStats === "function" ? session : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One roster row's usage: live observer progress, then persisted history, then
|
|
125
|
+
* the cached fallback read of a live session (populated by {@link aggregateMetrics}).
|
|
126
|
+
*/
|
|
127
|
+
export function hubRowMetrics<TRecord extends AgentRecordLike>(
|
|
128
|
+
ref: TRecord,
|
|
129
|
+
observed: ObservableSession | undefined,
|
|
130
|
+
sessionMetrics: WeakMap<object, { metrics: AgentMetrics | undefined }>,
|
|
131
|
+
): AgentMetrics | undefined {
|
|
132
|
+
if (observed?.progress) return progressMetrics(observed);
|
|
133
|
+
if (ref.history?.metrics) return ref.history.metrics;
|
|
134
|
+
const session = hubFallbackStatsSession(ref, observed);
|
|
135
|
+
return session ? sessionMetrics.get(session)?.metrics : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
export function aggregateMetrics<TRecord extends AgentRecordLike>(args: {
|
|
114
139
|
rows: readonly TRecord[];
|
|
115
140
|
observedById: ReadonlyMap<string, ObservableSession>;
|
|
@@ -49,7 +49,8 @@ import {
|
|
|
49
49
|
type AgentMetrics,
|
|
50
50
|
type AggregateMetrics,
|
|
51
51
|
aggregateMetrics,
|
|
52
|
-
|
|
52
|
+
hubFallbackStatsSession,
|
|
53
|
+
hubRowMetrics,
|
|
53
54
|
projectAgentTree,
|
|
54
55
|
STATUS_ORDER,
|
|
55
56
|
} from "./agent-hub-projection";
|
|
@@ -1661,19 +1662,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
1661
1662
|
}
|
|
1662
1663
|
|
|
1663
1664
|
#metricsFor(ref: TRecord, observed: ObservableSession | undefined): AgentMetrics | undefined {
|
|
1664
|
-
|
|
1665
|
-
if (ref.history?.metrics) return ref.history.metrics;
|
|
1666
|
-
const session = this.#fallbackStatsSession(ref, observed);
|
|
1667
|
-
return session ? this.#sessionMetrics.get(session)?.metrics : undefined;
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
#fallbackStatsSession(
|
|
1671
|
-
ref: TRecord,
|
|
1672
|
-
observed: ObservableSession | undefined,
|
|
1673
|
-
): NonNullable<TRecord["session"]> | undefined {
|
|
1674
|
-
if (observed?.progress) return undefined;
|
|
1675
|
-
const session = ref.session;
|
|
1676
|
-
return session && typeof session.getSessionStats === "function" ? session : undefined;
|
|
1665
|
+
return hubRowMetrics(ref, observed, this.#sessionMetrics);
|
|
1677
1666
|
}
|
|
1678
1667
|
|
|
1679
1668
|
// ========================================================================
|
|
@@ -2009,7 +1998,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
2009
1998
|
rows: this.#rows,
|
|
2010
1999
|
observedById: this.#observedById,
|
|
2011
2000
|
metricsFor: (ref, observed) => this.#metricsFor(ref, observed),
|
|
2012
|
-
fallbackStatsSession:
|
|
2001
|
+
fallbackStatsSession: hubFallbackStatsSession,
|
|
2013
2002
|
sessionMetrics: this.#sessionMetrics,
|
|
2014
2003
|
refreshFallback,
|
|
2015
2004
|
});
|
|
@@ -117,45 +117,32 @@ const DIALOG_HEIGHT_RATIO = 0.7;
|
|
|
117
117
|
const MIN_DIALOG_ROWS = 12;
|
|
118
118
|
const MIN_BODY_ROWS = 5;
|
|
119
119
|
const MAX_HEADER_CHIP_WIDTH = 16;
|
|
120
|
-
/** Maximum number of title lines shown in the prompt editor overlay, so a
|
|
121
|
-
* long or multi-line question cannot push the input row off-screen. Mirrors
|
|
122
|
-
* the bounded-title pattern from the legacy ask path without its option-window
|
|
123
|
-
* coupling. */
|
|
124
|
-
const MAX_PROMPT_TITLE_ROWS = 3;
|
|
125
|
-
/** Border (2) + padX (2) columns consumed by the HookEditor chrome. */
|
|
126
|
-
const PROMPT_TITLE_CHROME_COLUMNS = 4;
|
|
127
120
|
/** Maximum number of wrapped lines for an in-body question header, so a long
|
|
128
121
|
* or multi-line question cannot push the option list off-screen. Mirrors the
|
|
129
|
-
* row-cap pattern
|
|
122
|
+
* row-cap pattern of the prompt editor's title (`boundPromptTitle`). */
|
|
130
123
|
const MAX_HEADER_ROWS = 4;
|
|
131
124
|
/** Maximum number of wrapped lines shown for an option description before
|
|
132
125
|
* Ctrl+O expansion. Mirrors the header row-cap above so long descriptions
|
|
133
126
|
* cannot push later options off-screen. */
|
|
134
127
|
const MAX_DESC_ROWS = 2;
|
|
135
128
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
* multi-line questions stay usable inside the small prompt overlay. */
|
|
143
|
-
export function boundPromptTitle(prefix: string, question: string): string {
|
|
144
|
-
const width = promptTitleContentWidth();
|
|
145
|
-
const flat = normalizedInlineInput(`${prefix}${question}`);
|
|
146
|
-
const wrapped = wrapTextWithAnsi(flat, width);
|
|
147
|
-
if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
|
|
148
|
-
const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
|
|
149
|
-
const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, Ellipsis.Unicode);
|
|
150
|
-
return [...kept, last].join("\n");
|
|
129
|
+
/** What a custom-answer or note prompt asks: the host shows `title` over `question` (`HookEditorOptions.question`). */
|
|
130
|
+
export interface AskDialogPrompt {
|
|
131
|
+
/** `Custom answer`, `Note for <option>`. */
|
|
132
|
+
title: string;
|
|
133
|
+
/** The question being answered, verbatim. */
|
|
134
|
+
question: string;
|
|
151
135
|
}
|
|
152
136
|
|
|
153
137
|
interface AskDialogCallbacks {
|
|
154
138
|
onSubmit(result: ExtensionAskDialogSubmitResult): void;
|
|
155
139
|
onCancel(): void;
|
|
156
|
-
onPrompt(
|
|
140
|
+
onPrompt(prompt: AskDialogPrompt, prefill?: string): Promise<string | undefined>;
|
|
157
141
|
/** Prompt that accepts pasted images; without it, prompts use `onPrompt`. */
|
|
158
|
-
onImagePrompt?(
|
|
142
|
+
onImagePrompt?(
|
|
143
|
+
prompt: AskDialogPrompt,
|
|
144
|
+
prefill: AskDialogPromptValue | undefined,
|
|
145
|
+
): Promise<AskDialogPromptValue | undefined>;
|
|
159
146
|
}
|
|
160
147
|
|
|
161
148
|
interface AskDialogInputGuard {
|
|
@@ -1296,12 +1283,12 @@ export class AskDialogComponent implements Component {
|
|
|
1296
1283
|
* caller keeps one `await` before clearing `#promptActive`, which the host's restore relies on.
|
|
1297
1284
|
*/
|
|
1298
1285
|
#openPrompt(
|
|
1299
|
-
|
|
1286
|
+
prompt: AskDialogPrompt,
|
|
1300
1287
|
prefill: AskDialogPromptValue | undefined,
|
|
1301
1288
|
): Promise<string | AskDialogPromptValue | undefined> {
|
|
1302
1289
|
return this.callbacks.onImagePrompt
|
|
1303
|
-
? this.callbacks.onImagePrompt(
|
|
1304
|
-
: this.callbacks.onPrompt(
|
|
1290
|
+
? this.callbacks.onImagePrompt(prompt, prefill)
|
|
1291
|
+
: this.callbacks.onPrompt(prompt, prefill?.text);
|
|
1305
1292
|
}
|
|
1306
1293
|
|
|
1307
1294
|
async #promptForCustomInput(
|
|
@@ -1311,10 +1298,9 @@ export class AskDialogComponent implements Component {
|
|
|
1311
1298
|
): Promise<void> {
|
|
1312
1299
|
this.#promptActive = true;
|
|
1313
1300
|
try {
|
|
1314
|
-
const title = boundPromptTitle("Custom answer: ", question.question);
|
|
1315
1301
|
const prefill =
|
|
1316
1302
|
state.customInput === undefined ? undefined : { text: state.customInput, images: state.customInputImages };
|
|
1317
|
-
const result = await this.#openPrompt(title, prefill);
|
|
1303
|
+
const result = await this.#openPrompt({ title: "Custom answer", question: question.question }, prefill);
|
|
1318
1304
|
if (result === undefined || this.#closed) return;
|
|
1319
1305
|
const input = splitPromptInput(result);
|
|
1320
1306
|
if (input.text.trim() === "") {
|
|
@@ -1350,11 +1336,13 @@ export class AskDialogComponent implements Component {
|
|
|
1350
1336
|
): Promise<void> {
|
|
1351
1337
|
this.#promptActive = true;
|
|
1352
1338
|
try {
|
|
1353
|
-
const title = boundPromptTitle(`Note for ${rowItem.label}: `, question.question);
|
|
1354
1339
|
const isReedit = state.noteRowKey === rowItem.key;
|
|
1355
1340
|
const prefill =
|
|
1356
1341
|
isReedit && state.note !== undefined ? { text: state.note, images: state.noteImages } : undefined;
|
|
1357
|
-
const result = await this.#openPrompt(
|
|
1342
|
+
const result = await this.#openPrompt(
|
|
1343
|
+
{ title: `Note for ${rowItem.label}`, question: question.question },
|
|
1344
|
+
prefill,
|
|
1345
|
+
);
|
|
1358
1346
|
if (result === undefined || this.#closed) return;
|
|
1359
1347
|
const note = splitPromptInput(result);
|
|
1360
1348
|
state.note = note.text;
|
|
@@ -48,10 +48,10 @@ import { actionHint, hintsRow, type NativeHint, overlayCard } from "../native/ov
|
|
|
48
48
|
import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../native/picker";
|
|
49
49
|
|
|
50
50
|
/** Key of the leading list item that replays the history older than the startup tail. */
|
|
51
|
-
|
|
51
|
+
const EARLIER_TURNS_KEY = "earlier";
|
|
52
52
|
|
|
53
53
|
/** Leading item of a truncated transcript list; selecting it loads the older turns. */
|
|
54
|
-
|
|
54
|
+
const earlierTurnsItem: NativeNode = node(
|
|
55
55
|
"item",
|
|
56
56
|
{ label: [span("Earlier turns…", "muted")], hint: ["a"] },
|
|
57
57
|
undefined,
|
|
@@ -112,7 +112,7 @@ function entryTime(entry: TranscriptEntry): string {
|
|
|
112
112
|
* One list item per outline target, keyed by the turn's opening entry id:
|
|
113
113
|
* the plain turn summary as label, `detail` (or the entry time) on the right.
|
|
114
114
|
*/
|
|
115
|
-
|
|
115
|
+
function turnItem(target: OutlineTarget, detail?: TspText): NativeNode {
|
|
116
116
|
const entry = target.entries[0]!;
|
|
117
117
|
const { label, role } = turnSummary(entry);
|
|
118
118
|
const time = entryTime(entry);
|
|
@@ -164,14 +164,14 @@ function toolCallLabel(name: string, args: Record<string, unknown> | undefined):
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/** The timeline pickers' one fact column: a user turn's clock time. */
|
|
167
|
-
|
|
167
|
+
const TIMELINE_COLUMNS: readonly TspPickerColumn[] = [{ id: "at", format: "dim", priority: 1 }];
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* The `timeline` picker item of one outline target (rewind, copy), keyed by
|
|
171
171
|
* the turn's opening entry id: user turns carry their `HH:MM`, pure tool turns
|
|
172
172
|
* the tool's role and a `name target` label.
|
|
173
173
|
*/
|
|
174
|
-
|
|
174
|
+
function timelineItem(target: OutlineTarget): TspPickerItem {
|
|
175
175
|
const entry = target.entries[0]!;
|
|
176
176
|
const id = target.turnId;
|
|
177
177
|
const summary = turnSummary(entry);
|
|
@@ -203,7 +203,7 @@ export function timelineItem(target: OutlineTarget): TspPickerItem {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/** Timeline picker items for `targets`, reused while `targets` is the same array. */
|
|
206
|
-
|
|
206
|
+
class TimelineItems {
|
|
207
207
|
#memo: { targets: readonly OutlineTarget[]; items: TspPickerItem[] } | undefined;
|
|
208
208
|
|
|
209
209
|
of(targets: readonly OutlineTarget[]): TspPickerItem[] {
|
|
@@ -19,16 +19,21 @@ const MAX_BANNER_ROWS = 4;
|
|
|
19
19
|
* be missed. It is cleared when the next turn starts. The message wraps to the
|
|
20
20
|
* render width and keeps {@link MAX_BANNER_ROWS} rows; the expand hint on the
|
|
21
21
|
* overflow row reveals the full body inline in the transcript.
|
|
22
|
+
*
|
|
23
|
+
* Natively the transcript's error frame always shows the full message, so the
|
|
24
|
+
* strip is only a reminder and offers a Dismiss button that calls `onDismiss`.
|
|
22
25
|
*/
|
|
23
26
|
export class ErrorBannerComponent extends Container {
|
|
24
27
|
readonly #message: string;
|
|
28
|
+
readonly #onDismiss: (() => void) | undefined;
|
|
25
29
|
/** Natively the strip shows the headline; Details unfolds the whole message in place. */
|
|
26
30
|
#expanded = false;
|
|
27
31
|
#native: NativeNode | undefined;
|
|
28
32
|
|
|
29
|
-
constructor(message: string) {
|
|
33
|
+
constructor(message: string, onDismiss?: () => void) {
|
|
30
34
|
super();
|
|
31
35
|
this.#message = plainText(message).trim();
|
|
36
|
+
this.#onDismiss = onDismiss;
|
|
32
37
|
this.addChild(new Spacer(1));
|
|
33
38
|
this.addChild(new DynamicBorder(str => theme.fg("error", str)));
|
|
34
39
|
this.addChild(
|
|
@@ -50,7 +55,7 @@ export class ErrorBannerComponent extends Container {
|
|
|
50
55
|
/**
|
|
51
56
|
* An error-tinted strip pinned above the composer: the message's first line
|
|
52
57
|
* (the transcript's error frame keeps the full message and its actions)
|
|
53
|
-
* and a Details button that unfolds the rest here.
|
|
58
|
+
* and a Details button that unfolds the rest here, then Dismiss.
|
|
54
59
|
*/
|
|
55
60
|
override describe(): NativeNode {
|
|
56
61
|
if (this.#native) return this.#native;
|
|
@@ -67,6 +72,7 @@ export class ErrorBannerComponent extends Container {
|
|
|
67
72
|
if (multiline || headline.length > 80) {
|
|
68
73
|
children.push(actionButton(this.#expanded ? "Less" : "Details", "details"));
|
|
69
74
|
}
|
|
75
|
+
if (this.#onDismiss) children.push(actionButton("Dismiss", "dismiss"));
|
|
70
76
|
this.#native = node(
|
|
71
77
|
"row",
|
|
72
78
|
{ role: "omp.errorBanner", tone: "error", gap: "sm", align: this.#expanded ? "start" : "center" },
|
|
@@ -76,7 +82,12 @@ export class ErrorBannerComponent extends Container {
|
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
79
|
-
if (event.type !== "action"
|
|
85
|
+
if (event.type !== "action") return;
|
|
86
|
+
if (event.act === "dismiss") {
|
|
87
|
+
this.#onDismiss?.();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (event.act !== "details") return;
|
|
80
91
|
this.#expanded = !this.#expanded;
|
|
81
92
|
this.#native = undefined;
|
|
82
93
|
}
|
|
@@ -10,7 +10,18 @@
|
|
|
10
10
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { compactImageMarkers, formatVisionMarker, PLACEHOLDER_REGEX } from "../prompt/composer-attachments";
|
|
12
12
|
import { extractImagePastePathsFromText } from "../prompt/custom-editor";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Editor,
|
|
15
|
+
Ellipsis,
|
|
16
|
+
type Focusable,
|
|
17
|
+
matchesKey,
|
|
18
|
+
replaceTabs,
|
|
19
|
+
Spacer,
|
|
20
|
+
Text,
|
|
21
|
+
type TUI,
|
|
22
|
+
truncateToWidth,
|
|
23
|
+
wrapTextWithAnsi,
|
|
24
|
+
} from "../index";
|
|
14
25
|
import { BracketedPasteHandler } from "../bracketed-paste";
|
|
15
26
|
import { getEditorTheme, theme } from "../theme/theme";
|
|
16
27
|
import { matchesAppExternalEditor, matchesAppFollowUp, matchesAppInterrupt } from "../keybinding-matchers";
|
|
@@ -23,12 +34,39 @@ import { node, span } from "../native/describe";
|
|
|
23
34
|
import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
|
|
24
35
|
import { hintsRow, type NativeHint, overlayCard } from "../native/overlay";
|
|
25
36
|
import { plainText } from "../native/spans";
|
|
37
|
+
import { sanitizeCarriageReturns } from "../render/render-utils";
|
|
38
|
+
|
|
39
|
+
/** Most title rows the terminal prompt shows, so a long question cannot push the input row off-screen. */
|
|
40
|
+
const MAX_PROMPT_TITLE_ROWS = 3;
|
|
41
|
+
/** Border (2) + padX (2) columns consumed by the panel chrome. */
|
|
42
|
+
const PROMPT_TITLE_CHROME_COLUMNS = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `prefix` and `question` flattened onto one line, wrapped to the terminal's width and cut to
|
|
46
|
+
* {@link MAX_PROMPT_TITLE_ROWS} rows (the last ends in `…` when cut). For terminal rendering only:
|
|
47
|
+
* native hosts get the question whole ({@link HookEditorOptions.question}).
|
|
48
|
+
*/
|
|
49
|
+
export function boundPromptTitle(prefix: string, question: string): string {
|
|
50
|
+
const width = Math.max(1, (process.stdout.columns ?? 80) - PROMPT_TITLE_CHROME_COLUMNS);
|
|
51
|
+
const flat = replaceTabs(`${prefix}${question}`).replace(/\s+/g, " ").trim();
|
|
52
|
+
const wrapped = wrapTextWithAnsi(flat, width);
|
|
53
|
+
if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
|
|
54
|
+
const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
|
|
55
|
+
const last = truncateToWidth(wrapped.slice(MAX_PROMPT_TITLE_ROWS - 1).join(" "), width, Ellipsis.Unicode);
|
|
56
|
+
return [...kept, last].join("\n");
|
|
57
|
+
}
|
|
26
58
|
|
|
27
59
|
export interface HookEditorOptions {
|
|
28
60
|
/** Edit text with the host's configured external editor. */
|
|
29
61
|
externalEditor?: (text: string) => Promise<string | null>;
|
|
30
62
|
/** When true, use prompt-style keybindings with the legacy ask prompt chrome. */
|
|
31
63
|
promptStyle?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The question the text answers (the ask tool's custom answers and notes). The terminal
|
|
66
|
+
* shows it after `<title>: `, wrapped and cut to three rows; native hosts put it whole,
|
|
67
|
+
* as markdown, under the title.
|
|
68
|
+
*/
|
|
69
|
+
question?: string;
|
|
32
70
|
/** Allow clipboard images to be attached to this prompt. */
|
|
33
71
|
acceptImages?: boolean;
|
|
34
72
|
/** Images already represented by markers in the prefilled text. */
|
|
@@ -85,9 +123,11 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
|
|
|
85
123
|
options?: HookEditorOptions,
|
|
86
124
|
) {
|
|
87
125
|
// First title line insets into the panel border; remaining lines (e.g. the
|
|
88
|
-
// bounded ask question
|
|
89
|
-
//
|
|
90
|
-
const
|
|
126
|
+
// bounded ask question) stay as body rows so they are never truncated into
|
|
127
|
+
// the one-row border.
|
|
128
|
+
const question = options?.question;
|
|
129
|
+
const terminalTitle = question === undefined ? title : boundPromptTitle(`${title}: `, question);
|
|
130
|
+
const [titleLine = "", ...detailLines] = terminalTitle.split("\n");
|
|
91
131
|
super(titleLine, "omp.overlay.hook-editor");
|
|
92
132
|
|
|
93
133
|
this.#tui = tui;
|
|
@@ -151,7 +191,16 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
|
|
|
151
191
|
externalHint,
|
|
152
192
|
];
|
|
153
193
|
const nativeChildren: NativeChild[] = [];
|
|
154
|
-
if (
|
|
194
|
+
if (question !== undefined) {
|
|
195
|
+
nativeChildren.push(
|
|
196
|
+
node(
|
|
197
|
+
"md",
|
|
198
|
+
{ text: replaceTabs(sanitizeCarriageReturns(question)), role: "omp.ask.question" },
|
|
199
|
+
undefined,
|
|
200
|
+
"question",
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
} else if (detailLines.length > 0) {
|
|
155
204
|
nativeChildren.push(
|
|
156
205
|
node(
|
|
157
206
|
"text",
|
|
@@ -162,7 +211,11 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
|
|
|
162
211
|
);
|
|
163
212
|
}
|
|
164
213
|
nativeChildren.push(this.#editor, hintsRow(nativeHints));
|
|
165
|
-
this.#nativeRoot = overlayCard(
|
|
214
|
+
this.#nativeRoot = overlayCard(
|
|
215
|
+
this.nativeRole,
|
|
216
|
+
plainText(question === undefined ? titleLine : title),
|
|
217
|
+
nativeChildren,
|
|
218
|
+
);
|
|
166
219
|
}
|
|
167
220
|
|
|
168
221
|
/** The editor (which describes itself) under the title and detail lines, with the key hints below. */
|