@oh-my-pi/pi-tui 18.13.1 → 18.13.2
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 +20 -1
- package/dist/types/chrome/context-map.d.ts +15 -0
- package/dist/types/chrome/context-presentation.d.ts +48 -0
- package/dist/types/chrome/context-view-model.d.ts +77 -0
- package/dist/types/overlays/advisor-config.d.ts +6 -0
- package/dist/types/overlays/ask-dialog.d.ts +9 -2
- package/dist/types/overlays/context-viewer-format.d.ts +42 -0
- package/dist/types/overlays/context-viewer-overview.d.ts +70 -0
- package/dist/types/overlays/context-viewer.d.ts +25 -0
- package/dist/types/overlays/hook-input.d.ts +1 -0
- package/dist/types/overlays/running-subagent-badge.d.ts +7 -1
- package/dist/types/status-line/context-usage.d.ts +15 -30
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/tools/ask.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -0
- package/dist/types/tools/github.d.ts +3 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/wait.d.ts +6 -0
- package/dist/types/tui.d.ts +2 -0
- package/package.json +9 -9
- package/src/chrome/context-map.ts +62 -0
- package/src/chrome/context-presentation.ts +26 -0
- package/src/chrome/context-view-model.ts +73 -0
- package/src/components/editor.ts +23 -0
- package/src/components/markdown.ts +14 -2
- package/src/overlays/advisor-config.ts +9 -2
- package/src/overlays/ask-dialog.ts +55 -15
- package/src/overlays/context-viewer-format.ts +145 -0
- package/src/overlays/context-viewer-overview.ts +381 -0
- package/src/overlays/context-viewer.ts +728 -0
- package/src/overlays/hook-input.ts +2 -0
- package/src/overlays/running-subagent-badge.ts +12 -1
- package/src/render/output-pane.ts +3 -3
- package/src/status-line/context-usage.ts +56 -580
- package/src/theme/symbols.ts +16 -0
- package/src/tools/ask.ts +2 -0
- package/src/tools/bash.ts +5 -23
- package/src/tools/fetch.ts +1 -0
- package/src/tools/github.ts +23 -3
- package/src/tools/read.ts +3 -1
- package/src/tools/wait.ts +6 -0
- package/src/tui.ts +23 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Pure occupancy calculations for contiguous token segments, independent of themes and renderers. */
|
|
2
|
+
export interface ContextMapSegment<T> {
|
|
3
|
+
readonly id: T;
|
|
4
|
+
readonly tokens: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ContextMapCell<T> {
|
|
8
|
+
readonly id: T;
|
|
9
|
+
readonly full: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lays `segments` out in order over a `columns` × `rows` grid representing `windowTokens`; each cell takes the
|
|
14
|
+
* segment covering most of it and is `full` at 70% coverage or more. Returns `[]` when the window or grid
|
|
15
|
+
* size is unusable, and omits cells no segment touches.
|
|
16
|
+
*/
|
|
17
|
+
export function planContextMap<T>(
|
|
18
|
+
segments: readonly ContextMapSegment<T>[],
|
|
19
|
+
windowTokens: number,
|
|
20
|
+
columns: number,
|
|
21
|
+
rows: number,
|
|
22
|
+
): ContextMapCell<T>[] {
|
|
23
|
+
if (
|
|
24
|
+
!Number.isFinite(windowTokens) ||
|
|
25
|
+
windowTokens <= 0 ||
|
|
26
|
+
!Number.isInteger(columns) ||
|
|
27
|
+
!Number.isInteger(rows) ||
|
|
28
|
+
columns <= 0 ||
|
|
29
|
+
rows <= 0
|
|
30
|
+
)
|
|
31
|
+
return [];
|
|
32
|
+
const cellCount = columns * rows;
|
|
33
|
+
const tokensPerCell = windowTokens / cellCount;
|
|
34
|
+
const ranges: { id: T; start: number; end: number }[] = [];
|
|
35
|
+
let cursor = 0;
|
|
36
|
+
for (const segment of segments) {
|
|
37
|
+
if (!Number.isFinite(segment.tokens) || segment.tokens <= 0) continue;
|
|
38
|
+
const end = Math.min(windowTokens, cursor + segment.tokens);
|
|
39
|
+
if (end > cursor) ranges.push({ id: segment.id, start: cursor, end });
|
|
40
|
+
cursor = end;
|
|
41
|
+
if (cursor >= windowTokens) break;
|
|
42
|
+
}
|
|
43
|
+
const cells: ContextMapCell<T>[] = [];
|
|
44
|
+
let first = 0;
|
|
45
|
+
for (let index = 0; index < cellCount; index++) {
|
|
46
|
+
const start = index * tokensPerCell;
|
|
47
|
+
const end = (index + 1) * tokensPerCell;
|
|
48
|
+
while (first < ranges.length && ranges[first]!.end <= start) first++;
|
|
49
|
+
let winner: T | undefined;
|
|
50
|
+
let overlap = 0;
|
|
51
|
+
for (let part = first; part < ranges.length && ranges[part]!.start < end; part++) {
|
|
52
|
+
const range = ranges[part]!;
|
|
53
|
+
const covered = Math.min(end, range.end) - Math.max(start, range.start);
|
|
54
|
+
if (covered > overlap) {
|
|
55
|
+
overlap = covered;
|
|
56
|
+
winner = range.id;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (winner !== undefined) cells.push({ id: winner, full: overlap / tokensPerCell >= 0.7 });
|
|
60
|
+
}
|
|
61
|
+
return cells;
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ThemeColor } from "../theme";
|
|
2
|
+
import type { SymbolKey } from "../theme/symbols";
|
|
3
|
+
import type { ContextCategoryId } from "./context-view-model";
|
|
4
|
+
|
|
5
|
+
/** Shared category styles for maps and interactive legends; resolve glyphs at render time. */
|
|
6
|
+
const SYSTEM = { color: "accent", symbol: "context.content" } as const;
|
|
7
|
+
const DETAILS = { color: "customMessageLabel", symbol: "context.content" } as const;
|
|
8
|
+
const TOOLS = { color: "warning", symbol: "context.content" } as const;
|
|
9
|
+
const SKILLS = { color: "success", symbol: "context.content" } as const;
|
|
10
|
+
const MESSAGES = { color: "userMessageText", symbol: "context.messages" } as const;
|
|
11
|
+
const OUTPUT = { color: "muted", symbol: "context.messages" } as const;
|
|
12
|
+
export const CONTEXT_PRESENTATION = {
|
|
13
|
+
"system-prompt": SYSTEM,
|
|
14
|
+
skills: SKILLS,
|
|
15
|
+
"built-in-tools": TOOLS,
|
|
16
|
+
"custom-tools": DETAILS,
|
|
17
|
+
// Shares the System Prompt color; the message glyph tells them apart in the map.
|
|
18
|
+
"user-messages": { color: "accent", symbol: "context.messages" },
|
|
19
|
+
"assistant-messages": MESSAGES,
|
|
20
|
+
"assistant-thinking": DETAILS,
|
|
21
|
+
"tool-calls": TOOLS,
|
|
22
|
+
"tool-output": OUTPUT,
|
|
23
|
+
buffer: { color: "warning", symbol: "context.buffer" },
|
|
24
|
+
free: { color: "dim", symbol: "context.free" },
|
|
25
|
+
} as const satisfies Record<ContextPresentationId, { color: ThemeColor; symbol: SymbolKey }>;
|
|
26
|
+
export type ContextPresentationId = ContextCategoryId | "buffer" | "free";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Context snapshot shapes shared by the session snapshot builder and the context viewer. */
|
|
2
|
+
import type { ContextBreakdown } from "../status-line/context-usage";
|
|
3
|
+
|
|
4
|
+
interface ContextCategoryDescriptor {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
/** Overview row label when the Overview weight differs from the category's content. */
|
|
8
|
+
readonly overviewLabel?: string;
|
|
9
|
+
/** Listed with its weight but never opened. */
|
|
10
|
+
readonly openable?: false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Context categories in display order. */
|
|
14
|
+
export const CONTEXT_CATEGORIES = [
|
|
15
|
+
// The Overview weight excludes the Skills listing; content views keep complete system blocks.
|
|
16
|
+
{ id: "system-prompt", label: "System Prompt", overviewLabel: "System Prompt (excluding Skills)" },
|
|
17
|
+
// An estimate of the prompt's skill listing, without entries of its own.
|
|
18
|
+
{ id: "skills", label: "Skills", openable: false },
|
|
19
|
+
{ id: "built-in-tools", label: "Built-in Tools" },
|
|
20
|
+
{ id: "custom-tools", label: "Custom Tools" },
|
|
21
|
+
{ id: "user-messages", label: "User / Developer Messages" },
|
|
22
|
+
{ id: "assistant-messages", label: "Assistant Messages" },
|
|
23
|
+
{ id: "assistant-thinking", label: "Assistant Thinking" },
|
|
24
|
+
{ id: "tool-calls", label: "Tool Calls" },
|
|
25
|
+
{ id: "tool-output", label: "Tool Output" },
|
|
26
|
+
] as const satisfies readonly ContextCategoryDescriptor[];
|
|
27
|
+
|
|
28
|
+
export type ContextCategoryId = (typeof CONTEXT_CATEGORIES)[number]["id"];
|
|
29
|
+
|
|
30
|
+
const DESCRIPTORS_BY_ID = new Map<ContextCategoryId, ContextCategoryDescriptor>(
|
|
31
|
+
CONTEXT_CATEGORIES.map(descriptor => [descriptor.id, descriptor]),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Descriptor of a category id: its label, optional Overview label, and whether it can be opened. */
|
|
35
|
+
export function contextCategoryDescriptor(id: ContextCategoryId): ContextCategoryDescriptor {
|
|
36
|
+
return DESCRIPTORS_BY_ID.get(id)!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContextThinkingGroup {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly label: string;
|
|
42
|
+
readonly tokens: number;
|
|
43
|
+
readonly interrupted?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ContextViewEntry {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly label: string;
|
|
49
|
+
readonly text: string;
|
|
50
|
+
/** Aggregation weight for the unscaled replay estimate; shared blocks contribute through their group. */
|
|
51
|
+
readonly tokens: number;
|
|
52
|
+
readonly interrupted?: boolean;
|
|
53
|
+
readonly thinkingGroup?: ContextThinkingGroup;
|
|
54
|
+
readonly path?: string;
|
|
55
|
+
readonly info?: string;
|
|
56
|
+
readonly timestamp?: number;
|
|
57
|
+
readonly callId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ContextViewCategory {
|
|
61
|
+
id: ContextCategoryId;
|
|
62
|
+
key?: string;
|
|
63
|
+
label: string;
|
|
64
|
+
/** Overview attribution weight, not reported usage; may differ from the entry sum (e.g. Skills subtraction). */
|
|
65
|
+
tokens: number;
|
|
66
|
+
entries: ContextViewEntry[];
|
|
67
|
+
children?: ContextViewCategory[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ContextViewData {
|
|
71
|
+
readonly breakdown: ContextBreakdown;
|
|
72
|
+
readonly categories: readonly ContextViewCategory[];
|
|
73
|
+
}
|
package/src/components/editor.ts
CHANGED
|
@@ -2431,10 +2431,12 @@ export class Editor implements Component, Focusable {
|
|
|
2431
2431
|
// Word left
|
|
2432
2432
|
this.#resetKillSequence();
|
|
2433
2433
|
this.#moveWordBackwards();
|
|
2434
|
+
this.#dismissAutocomplete();
|
|
2434
2435
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordRight")) {
|
|
2435
2436
|
// Word right
|
|
2436
2437
|
this.#resetKillSequence();
|
|
2437
2438
|
this.#moveWordForwards();
|
|
2439
|
+
this.#dismissAutocomplete();
|
|
2438
2440
|
}
|
|
2439
2441
|
// Arrow keys
|
|
2440
2442
|
else if (kb.matchesCanonical(canonical, "tui.editor.cursorUp")) {
|
|
@@ -3900,6 +3902,9 @@ export class Editor implements Component, Focusable {
|
|
|
3900
3902
|
const dropSpace = this.#hasProvisionalSpace();
|
|
3901
3903
|
this.#historyIndex = -1; // Exit history browsing mode
|
|
3902
3904
|
this.#resetKillSequence();
|
|
3905
|
+
// The cursor leaves the trigger token's line, so any open popup is stale:
|
|
3906
|
+
// keeping it would float on the new line and Enter would submit past it.
|
|
3907
|
+
this.#dismissAutocomplete();
|
|
3903
3908
|
this.#recordUndoState();
|
|
3904
3909
|
|
|
3905
3910
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -4201,23 +4206,27 @@ export class Editor implements Component, Focusable {
|
|
|
4201
4206
|
|
|
4202
4207
|
#moveToLineStart(): void {
|
|
4203
4208
|
this.#resetKillSequence();
|
|
4209
|
+
this.#dismissAutocomplete();
|
|
4204
4210
|
this.#setCursorCol(0);
|
|
4205
4211
|
}
|
|
4206
4212
|
|
|
4207
4213
|
#moveToLineEnd(): void {
|
|
4208
4214
|
this.#resetKillSequence();
|
|
4215
|
+
this.#dismissAutocomplete();
|
|
4209
4216
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
4210
4217
|
this.#setCursorCol(currentLine.length);
|
|
4211
4218
|
}
|
|
4212
4219
|
|
|
4213
4220
|
#moveToMessageStart(): void {
|
|
4214
4221
|
this.#resetKillSequence();
|
|
4222
|
+
this.#dismissAutocomplete();
|
|
4215
4223
|
this.#state.cursorLine = 0;
|
|
4216
4224
|
this.#setCursorCol(0);
|
|
4217
4225
|
}
|
|
4218
4226
|
|
|
4219
4227
|
#moveToMessageEnd(): void {
|
|
4220
4228
|
this.#resetKillSequence();
|
|
4229
|
+
this.#dismissAutocomplete();
|
|
4221
4230
|
this.#state.cursorLine = this.#state.lines.length - 1;
|
|
4222
4231
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
4223
4232
|
this.#setCursorCol(currentLine.length);
|
|
@@ -4697,6 +4706,7 @@ export class Editor implements Component, Focusable {
|
|
|
4697
4706
|
|
|
4698
4707
|
#moveCursor(deltaLine: number, deltaCol: number): void {
|
|
4699
4708
|
this.#resetKillSequence();
|
|
4709
|
+
this.#dismissAutocomplete();
|
|
4700
4710
|
const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
|
|
4701
4711
|
const currentVisualLine = this.#findCurrentVisualLine(visualLines);
|
|
4702
4712
|
|
|
@@ -4749,6 +4759,7 @@ export class Editor implements Component, Focusable {
|
|
|
4749
4759
|
|
|
4750
4760
|
#pageScroll(direction: -1 | 1): void {
|
|
4751
4761
|
this.#resetKillSequence();
|
|
4762
|
+
this.#dismissAutocomplete();
|
|
4752
4763
|
const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
|
|
4753
4764
|
const currentVisualLine = this.#findCurrentVisualLine(visualLines);
|
|
4754
4765
|
const step = this.#getPageScrollStep(visualLines.length);
|
|
@@ -4779,6 +4790,7 @@ export class Editor implements Component, Focusable {
|
|
|
4779
4790
|
*/
|
|
4780
4791
|
#jumpToChar(char: string, direction: "forward" | "backward"): void {
|
|
4781
4792
|
this.#resetKillSequence();
|
|
4793
|
+
this.#dismissAutocomplete();
|
|
4782
4794
|
const isForward = direction === "forward";
|
|
4783
4795
|
const lines = this.#state.lines;
|
|
4784
4796
|
|
|
@@ -5195,6 +5207,17 @@ export class Editor implements Component, Focusable {
|
|
|
5195
5207
|
}
|
|
5196
5208
|
}
|
|
5197
5209
|
|
|
5210
|
+
/**
|
|
5211
|
+
* Close the popup when the cursor leaves the token it was opened for (newline,
|
|
5212
|
+
* pure cursor motion). Its prefix no longer matches the text before the cursor,
|
|
5213
|
+
* so it could neither accept nor refresh and would linger stale.
|
|
5214
|
+
*/
|
|
5215
|
+
#dismissAutocomplete(): void {
|
|
5216
|
+
if (this.#autocompleteState === null) return;
|
|
5217
|
+
this.#cancelAutocomplete();
|
|
5218
|
+
this.onAutocompleteUpdate?.();
|
|
5219
|
+
}
|
|
5220
|
+
|
|
5198
5221
|
/**
|
|
5199
5222
|
* Whether an autocomplete popup is on screen. An `@` list narrowed to no match
|
|
5200
5223
|
* while its refresh is pending stays open internally but is hidden, so it does
|
|
@@ -4346,7 +4346,13 @@ export class Markdown implements Component {
|
|
|
4346
4346
|
|
|
4347
4347
|
// Process item tokens; nested-list lines arrive structurally tagged and
|
|
4348
4348
|
// already carry their own full indent.
|
|
4349
|
-
const itemLines = this.#renderListItem(
|
|
4349
|
+
const itemLines = this.#renderListItem(
|
|
4350
|
+
item.tokens || [],
|
|
4351
|
+
depth,
|
|
4352
|
+
width,
|
|
4353
|
+
Math.max(1, width - visibleWidth(continuationIndent)),
|
|
4354
|
+
styleContext,
|
|
4355
|
+
);
|
|
4350
4356
|
|
|
4351
4357
|
if (itemLines.length > 0) {
|
|
4352
4358
|
const firstLine = itemLines[0]!;
|
|
@@ -4398,11 +4404,13 @@ export class Markdown implements Component {
|
|
|
4398
4404
|
tokens: Token[],
|
|
4399
4405
|
parentDepth: number,
|
|
4400
4406
|
width: number,
|
|
4407
|
+
contentWidth: number,
|
|
4401
4408
|
styleContext?: InlineStyleContext,
|
|
4402
4409
|
): RenderedListItemLine[] {
|
|
4403
4410
|
const lines: RenderedListItemLine[] = [];
|
|
4404
4411
|
|
|
4405
|
-
for (
|
|
4412
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
4413
|
+
const token = tokens[i]!;
|
|
4406
4414
|
if (token.type === "list") {
|
|
4407
4415
|
// Nested list - render with one additional indent level
|
|
4408
4416
|
// These lines carry their own indent, so tag them for pass-through
|
|
@@ -4442,6 +4450,10 @@ export class Markdown implements Component {
|
|
|
4442
4450
|
lines.push({ ...bodyLine, nested: false });
|
|
4443
4451
|
}
|
|
4444
4452
|
lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
|
|
4453
|
+
} else if (token.type === "table") {
|
|
4454
|
+
for (const row of this.#renderTable(token as TableToken, contentWidth, tokens[i + 1]?.type, styleContext)) {
|
|
4455
|
+
lines.push({ text: row, nested: false });
|
|
4456
|
+
}
|
|
4445
4457
|
} else if (isMathToken(token)) {
|
|
4446
4458
|
// Display math block inside a list item: stack fractions / matrix rows.
|
|
4447
4459
|
const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
|
|
@@ -64,10 +64,11 @@ import { getThinkingLevelMetadata, parseThinkingLevel } from "../thinking";
|
|
|
64
64
|
|
|
65
65
|
export const ADVISOR_REVIEW_MODES = ["turn", "agent-end"] as const;
|
|
66
66
|
export const ADVISOR_SYNC_BACKLOG_MODES = ["off", "1", "3", "5", "strict"] as const;
|
|
67
|
+
export const ADVISOR_SESSION_SCOPES = ["main", "subagent", "all"] as const;
|
|
67
68
|
|
|
68
69
|
export type AdvisorReviewMode = (typeof ADVISOR_REVIEW_MODES)[number];
|
|
69
70
|
export type AdvisorSyncBacklog = (typeof ADVISOR_SYNC_BACKLOG_MODES)[number];
|
|
70
|
-
|
|
71
|
+
export type AdvisorSessionScope = (typeof ADVISOR_SESSION_SCOPES)[number];
|
|
71
72
|
/** One advisor declared in `WATCHDOG.yml`; its instructions specialize the shared baseline. */
|
|
72
73
|
export interface AdvisorConfig {
|
|
73
74
|
name: string;
|
|
@@ -83,6 +84,10 @@ export interface AdvisorConfig {
|
|
|
83
84
|
reviewMode?: AdvisorReviewMode;
|
|
84
85
|
reviewInterval?: number;
|
|
85
86
|
syncBacklog?: AdvisorSyncBacklog;
|
|
87
|
+
/** Which session kinds this advisor attaches to (`main`, `subagent`, or `all`; defaults to `all`). */
|
|
88
|
+
scope?: AdvisorSessionScope;
|
|
89
|
+
/** Whether primary assistant thinking blocks are included in updates sent to this advisor (defaults to `true`). */
|
|
90
|
+
includeReasoning?: boolean;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
|
|
@@ -1202,7 +1207,9 @@ export class AdvisorConfigOverlayComponent implements Component {
|
|
|
1202
1207
|
advisor.syncBacklog === undefined &&
|
|
1203
1208
|
!advisor.instructions?.trim() &&
|
|
1204
1209
|
advisor.enabled !== false &&
|
|
1205
|
-
advisor.maxNotesPerUpdate === undefined
|
|
1210
|
+
advisor.maxNotesPerUpdate === undefined &&
|
|
1211
|
+
advisor.scope === undefined &&
|
|
1212
|
+
advisor.includeReasoning === undefined
|
|
1206
1213
|
);
|
|
1207
1214
|
}
|
|
1208
1215
|
|
|
@@ -147,14 +147,21 @@ interface AskDialogCallbacks {
|
|
|
147
147
|
): Promise<AskDialogPromptValue | undefined>;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Routes typing to a composer draft that was in progress when the dialog
|
|
152
|
+
* opened, so in-flight keystrokes never answer by accident. The up/down select
|
|
153
|
+
* keys are the explicit hand-off: they release the guard for the rest of the
|
|
154
|
+
* dialog's life and the draft stays parked (intact) beneath it.
|
|
155
|
+
*/
|
|
150
156
|
interface AskDialogInputGuard {
|
|
151
157
|
isBlocked(): boolean;
|
|
152
158
|
handleInput(keyData: string): void;
|
|
159
|
+
/** Shown after the release keys while the guard holds input. */
|
|
153
160
|
hint: string;
|
|
154
161
|
/** Mirror the guard's blocked state onto the proxied draft surface each
|
|
155
162
|
* render, so a draft that owns input shows a visible insertion cursor even
|
|
156
|
-
* though this dialog holds TUI focus. */
|
|
157
|
-
syncPresentation?(): void;
|
|
163
|
+
* though this dialog holds TUI focus, and drops it once the dialog takes over. */
|
|
164
|
+
syncPresentation?(blocked: boolean): void;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
167
|
interface AskDialogOptions {
|
|
@@ -587,6 +594,8 @@ export class AskDialogComponent implements Component {
|
|
|
587
594
|
#native: NativeNode | undefined;
|
|
588
595
|
/** Input-guard blocked state {@link #native} was described with (the guard flips outside this dialog). */
|
|
589
596
|
#nativeBlocked = false;
|
|
597
|
+
/** Set once the user explicitly takes the dialog (up/down, or a pointer action) while a draft held input. */
|
|
598
|
+
#guardReleased = false;
|
|
590
599
|
/** When the countdown runs out (epoch ms), for the native ring. */
|
|
591
600
|
#countdownDeadline = 0;
|
|
592
601
|
/** Once-a-second re-describe while the native countdown ring is shown. */
|
|
@@ -709,9 +718,15 @@ export class AskDialogComponent implements Component {
|
|
|
709
718
|
this.toggleQuestionExpansion();
|
|
710
719
|
return;
|
|
711
720
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
721
|
+
if (this.#guardBlocks()) {
|
|
722
|
+
if (
|
|
723
|
+
getKeybindings().matches(keyData, "tui.select.up") ||
|
|
724
|
+
getKeybindings().matches(keyData, "tui.select.down")
|
|
725
|
+
) {
|
|
726
|
+
this.#releaseGuard();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.options.inputGuard?.handleInput(keyData);
|
|
715
730
|
this.#requestRender();
|
|
716
731
|
return;
|
|
717
732
|
}
|
|
@@ -736,7 +751,7 @@ export class AskDialogComponent implements Component {
|
|
|
736
751
|
// Keep the proxied draft's cursor visible while it owns input (the editor
|
|
737
752
|
// renders as the next sibling in the same container, so this lands in the
|
|
738
753
|
// same frame).
|
|
739
|
-
this.options.inputGuard?.syncPresentation?.();
|
|
754
|
+
this.options.inputGuard?.syncPresentation?.(this.#guardBlocks());
|
|
740
755
|
if (this.#hidden) return this.#renderHidden(width);
|
|
741
756
|
const innerWidth = Math.max(1, width - 4);
|
|
742
757
|
this.#contentWidth = innerWidth;
|
|
@@ -781,10 +796,9 @@ export class AskDialogComponent implements Component {
|
|
|
781
796
|
* countdown, Hide, then Note, Skip and the primary Submit/Next).
|
|
782
797
|
*/
|
|
783
798
|
describe(cx: DescribeContext): NativeNode {
|
|
784
|
-
const inputGuard = this.options.inputGuard;
|
|
785
799
|
// Same draft-cursor mirroring as render(): the proxied draft describes itself next to this dialog.
|
|
786
|
-
|
|
787
|
-
|
|
800
|
+
const blocked = this.#guardBlocks();
|
|
801
|
+
this.options.inputGuard?.syncPresentation?.(blocked);
|
|
788
802
|
const ring = this.#countdown !== undefined && cx.supports("meter");
|
|
789
803
|
if (ring) this.#startRingTick();
|
|
790
804
|
if (this.#native && this.#nativeBlocked === blocked) return this.#native;
|
|
@@ -998,7 +1012,7 @@ export class AskDialogComponent implements Component {
|
|
|
998
1012
|
if (blocked && inputGuard) {
|
|
999
1013
|
return actionBar([
|
|
1000
1014
|
...lead,
|
|
1001
|
-
text([span(plainText(inputGuard
|
|
1015
|
+
text([span(plainText(this.#guardHint(inputGuard)), "muted")], { truncate: "end", role: "omp.ask.summary" }),
|
|
1002
1016
|
skip,
|
|
1003
1017
|
]);
|
|
1004
1018
|
}
|
|
@@ -1045,13 +1059,16 @@ export class AskDialogComponent implements Component {
|
|
|
1045
1059
|
this.#setHidden(event.act === "hide");
|
|
1046
1060
|
return;
|
|
1047
1061
|
}
|
|
1048
|
-
if (this.#hidden
|
|
1062
|
+
if (this.#hidden) return;
|
|
1063
|
+
// A pointer action on the dialog is an explicit hand-off from the draft.
|
|
1064
|
+
this.#releaseGuardForPointer();
|
|
1049
1065
|
if (event.act === "submit") this.#pressEnter();
|
|
1050
1066
|
else if (event.act === "note") this.#pressNote();
|
|
1051
1067
|
return;
|
|
1052
1068
|
}
|
|
1053
1069
|
if (event.type !== "select" && event.type !== "activate") return;
|
|
1054
|
-
if (this.#hidden
|
|
1070
|
+
if (this.#hidden) return;
|
|
1071
|
+
this.#releaseGuardForPointer();
|
|
1055
1072
|
const key = leafKey(event.key);
|
|
1056
1073
|
if (key === "tabs") {
|
|
1057
1074
|
if (!this.#hasSubmitTab()) return;
|
|
@@ -1212,16 +1229,39 @@ export class AskDialogComponent implements Component {
|
|
|
1212
1229
|
this.#requestRender();
|
|
1213
1230
|
}
|
|
1214
1231
|
|
|
1232
|
+
/** True while a draft that predates the dialog still owns typing. */
|
|
1233
|
+
#guardBlocks(): boolean {
|
|
1234
|
+
return !this.#guardReleased && (this.options.inputGuard?.isBlocked() ?? false);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Up/down hand the keyboard to the dialog for good; the draft stays untouched beneath it. */
|
|
1238
|
+
#releaseGuard(): void {
|
|
1239
|
+
this.#guardReleased = true;
|
|
1240
|
+
this.#native = undefined;
|
|
1241
|
+
this.#hidden = false;
|
|
1242
|
+
this.#requestRender();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
#releaseGuardForPointer(): void {
|
|
1246
|
+
if (!this.#guardBlocks()) return;
|
|
1247
|
+
this.#guardReleased = true;
|
|
1248
|
+
this.#native = undefined;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
#guardHint(inputGuard: AskDialogInputGuard): string {
|
|
1252
|
+
return `${editorKeys("tui.select.up", "tui.select.down")} answer · ${inputGuard.hint}`;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1215
1255
|
/** Hidden: one line in the composer's place — title (with countdown), the question clipped to fit, and how to bring it back. */
|
|
1216
1256
|
#renderHidden(width: number): readonly string[] {
|
|
1217
1257
|
const hideKey = editorKey("app.ask.hide");
|
|
1218
1258
|
const enter = formatKeyHint("enter");
|
|
1219
1259
|
const inputGuard = this.options.inputGuard;
|
|
1220
|
-
const blocked =
|
|
1260
|
+
const blocked = this.#guardBlocks();
|
|
1221
1261
|
// While a draft owns input, Enter goes to the draft, so only the hide key restores.
|
|
1222
1262
|
const show = blocked ? hideKey : hideKey ? `${hideKey}/${enter}` : enter;
|
|
1223
1263
|
const hints: string[] = [];
|
|
1224
|
-
if (blocked && inputGuard) hints.push(inputGuard
|
|
1264
|
+
if (blocked && inputGuard) hints.push(this.#guardHint(inputGuard));
|
|
1225
1265
|
if (show) hints.push(`${show} show`);
|
|
1226
1266
|
hints.push(`${editorKey("tui.select.cancel")} cancel`);
|
|
1227
1267
|
const hint = theme.fg("dim", ` · ${hints.join(" · ")}`);
|
|
@@ -1305,7 +1345,7 @@ export class AskDialogComponent implements Component {
|
|
|
1305
1345
|
const enter = formatKeyHint("enter");
|
|
1306
1346
|
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
1307
1347
|
const inputGuard = this.options.inputGuard;
|
|
1308
|
-
if (inputGuard
|
|
1348
|
+
if (inputGuard && this.#guardBlocks()) return `${this.#guardHint(inputGuard)}${this.#expandHint()} · ${cancel}`;
|
|
1309
1349
|
if (this.#isSubmitTab()) {
|
|
1310
1350
|
const scroll = indicator ? ` ${indicator} scroll ·` : "";
|
|
1311
1351
|
return `${enter} submit · ${upDown} scroll ·${scroll} ${cancel}`;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display text for context entries. Each header has an ANSI-string form (`render`)
|
|
3
|
+
* and a span form (`describe`); keep the twins visually identical.
|
|
4
|
+
*/
|
|
5
|
+
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import type { TspSpan } from "@oh-my-pi/pi-wire";
|
|
7
|
+
import { CONTEXT_PRESENTATION } from "../chrome/context-presentation";
|
|
8
|
+
import type { ContextCategoryId, ContextThinkingGroup, ContextViewEntry } from "../chrome/context-view-model";
|
|
9
|
+
import { editorKey } from "../chrome/keybinding-hints";
|
|
10
|
+
import { span } from "../native/describe";
|
|
11
|
+
import { PREVIEW_LIMITS, replaceTabs, shortenEmbeddedPaths, shortenPath } from "../render/render-utils";
|
|
12
|
+
import { theme } from "../theme/theme";
|
|
13
|
+
import { wrapTextWithAnsi } from "../utils";
|
|
14
|
+
|
|
15
|
+
const TIMESTAMP_FORMAT = {
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "2-digit",
|
|
18
|
+
day: "2-digit",
|
|
19
|
+
hour: "2-digit",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
/** Normalized newlines, control characters removed, tabs expanded. */
|
|
24
|
+
export function safeContent(value: string): string {
|
|
25
|
+
return replaceTabs(sanitizeText(value.replace(/\r\n?/g, "\n")));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** {@link safeContent} with embedded home paths shortened, for labels and metadata. */
|
|
29
|
+
export function safeText(value: string): string {
|
|
30
|
+
return shortenEmbeddedPaths(safeContent(value));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** {@link safeText} collapsed to one row. */
|
|
34
|
+
export function safeLine(value: string): string {
|
|
35
|
+
return safeText(value).replace(/\n/g, " ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Token label of an entry or group: `—` for a grouped entry, `N/A · Interrupted`, else `N token(s)` (unscaled estimate). */
|
|
39
|
+
export function tokenEstimate(item: ContextViewEntry | ContextThinkingGroup): string {
|
|
40
|
+
// A grouped entry shares its group's estimate, which the group heading shows.
|
|
41
|
+
if ("thinkingGroup" in item && item.thinkingGroup) return "—";
|
|
42
|
+
if (item.interrupted) return "N/A · Interrupted";
|
|
43
|
+
return `${item.tokens.toLocaleString("en-US")} ${item.tokens === 1 ? "token" : "tokens"}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `[label] N tokens` as ANSI. */
|
|
47
|
+
export function entryHeaderLine(entry: ContextViewEntry, category: ContextCategoryId): string {
|
|
48
|
+
const color = CONTEXT_PRESENTATION[category].color;
|
|
49
|
+
return (
|
|
50
|
+
theme.fg("dim", "[") +
|
|
51
|
+
theme.fg(color, theme.bold(safeLine(entry.label))) +
|
|
52
|
+
theme.fg("dim", `] ${tokenEstimate(entry)}`)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `[label] N tokens` as spans. */
|
|
57
|
+
export function entryHeaderSpans(entry: ContextViewEntry, category: ContextCategoryId): TspSpan[] {
|
|
58
|
+
const color = CONTEXT_PRESENTATION[category].color;
|
|
59
|
+
return [span("[", "dim"), span(safeLine(entry.label), `${color} strong`), span(`] ${tokenEstimate(entry)}`, "dim")];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Heading for a shared thinking group: label plus its token estimate. */
|
|
63
|
+
export function groupHeaderLine(group: ContextThinkingGroup, category: ContextCategoryId): string {
|
|
64
|
+
const color = CONTEXT_PRESENTATION[category].color;
|
|
65
|
+
return theme.fg(color, theme.bold(safeLine(group.label))) + theme.fg("dim", ` · ${tokenEstimate(group)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Truncation marker under a preview; the selected preview also advertises the open key. */
|
|
69
|
+
export function previewHint(selected: boolean): string {
|
|
70
|
+
const confirm = selected ? editorKey("tui.select.confirm") : "";
|
|
71
|
+
return (
|
|
72
|
+
theme.fg("dim", " … more") +
|
|
73
|
+
(confirm ? theme.fg("dim", " · ") + theme.fg("accent", `${confirm} View Content`) : "")
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Metadata rows: info and path, call id, timestamp. The full-content view also
|
|
79
|
+
* lists the thinking group, which previews show as a shared heading instead.
|
|
80
|
+
*/
|
|
81
|
+
export function entryMetadata(
|
|
82
|
+
entry: ContextViewEntry,
|
|
83
|
+
options: { hour12: boolean | undefined; withGroup: boolean },
|
|
84
|
+
): string[] {
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const group = entry.thinkingGroup;
|
|
87
|
+
if (group && options.withGroup) lines.push(`${safeLine(group.label)} · ${tokenEstimate(group)}`);
|
|
88
|
+
const info = safeText([entry.info, entry.path ? shortenPath(entry.path) : undefined].filter(Boolean).join(" · "));
|
|
89
|
+
if (info) lines.push(info);
|
|
90
|
+
const callId = entry.callId !== undefined ? safeText(entry.callId) : "";
|
|
91
|
+
if (callId) lines.push(callId);
|
|
92
|
+
if (entry.timestamp !== undefined)
|
|
93
|
+
lines.push(new Date(entry.timestamp).toLocaleString([], { ...TIMESTAMP_FORMAT, hour12: options.hour12 }));
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Preview row budget for a list of `height` rows: room for the entry heading, hint, and group heading. */
|
|
98
|
+
export function previewRows(entry: ContextViewEntry, height: number): number {
|
|
99
|
+
const reserved = entry.thinkingGroup ? 3 : 2;
|
|
100
|
+
return Math.max(1, Math.min(PREVIEW_LIMITS.OUTPUT_EXPANDED, height - reserved));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EntryPreviewOptions {
|
|
104
|
+
/** Wrap width of preview rows. */
|
|
105
|
+
readonly width: number;
|
|
106
|
+
/** Maximum preview rows. */
|
|
107
|
+
readonly rows: number;
|
|
108
|
+
/** Viewport width; bounds the source read to `sourceWidth × rows × 2` characters before sanitizing and wrapping. */
|
|
109
|
+
readonly sourceWidth: number;
|
|
110
|
+
readonly hour12: boolean | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Dim metadata rows, then the indented body, cut to `rows`; `complete` is false when anything was omitted. */
|
|
114
|
+
export function entryPreview(
|
|
115
|
+
entry: ContextViewEntry,
|
|
116
|
+
options: EntryPreviewOptions,
|
|
117
|
+
): { lines: string[]; complete: boolean } {
|
|
118
|
+
const { width, rows } = options;
|
|
119
|
+
const maxChars = Math.max(1, options.sourceWidth) * rows * 2;
|
|
120
|
+
const metadata = entryMetadata(entry, { hour12: options.hour12, withGroup: false }).join("\n");
|
|
121
|
+
const metadataLength = Math.min(metadata.length, maxChars);
|
|
122
|
+
// Providers often end thinking and tool output with newlines; they render as empty rows.
|
|
123
|
+
const body = entry.text.trimEnd();
|
|
124
|
+
const bodyLength = Math.min(body.length, Math.max(0, maxChars - metadataLength - (metadataLength ? 1 : 0)));
|
|
125
|
+
const parts = [
|
|
126
|
+
{
|
|
127
|
+
value: safeContent(metadata.slice(0, metadataLength)),
|
|
128
|
+
prefix: "",
|
|
129
|
+
style: (line: string) => theme.fg("dim", line),
|
|
130
|
+
},
|
|
131
|
+
{ value: safeContent(body.slice(0, bodyLength)), prefix: " ", style: (line: string) => line },
|
|
132
|
+
];
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
for (const { value, prefix, style } of parts) {
|
|
135
|
+
if (!value) continue;
|
|
136
|
+
for (const paragraph of value.split("\n")) {
|
|
137
|
+
const remaining = rows - lines.length;
|
|
138
|
+
if (remaining === 0) return { lines, complete: false };
|
|
139
|
+
const wrapped = paragraph ? wrapTextWithAnsi(style(paragraph), Math.max(1, width - prefix.length)) : [""];
|
|
140
|
+
lines.push(...wrapped.slice(0, remaining).map(line => `${prefix}${line}`));
|
|
141
|
+
if (wrapped.length > remaining) return { lines, complete: false };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { lines, complete: metadataLength === metadata.length && bodyLength === body.length };
|
|
145
|
+
}
|