@oh-my-pi/pi-tui 18.12.2 → 18.12.3
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 +17 -0
- package/dist/types/autocomplete.d.ts +6 -0
- package/dist/types/chat/skill-message.d.ts +3 -4
- package/dist/types/components/sparkline.d.ts +34 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/native/describe.d.ts +7 -1
- package/dist/types/native/tone.d.ts +2 -0
- package/dist/types/overlays/agent-hub.d.ts +3 -3
- package/dist/types/overlays/approval-dialog.d.ts +15 -1
- package/dist/types/overlays/jobs-panel.d.ts +3 -0
- package/dist/types/overlays/model-browser.d.ts +1 -1
- package/dist/types/sixel.d.ts +28 -0
- package/dist/types/status-line/host.d.ts +3 -0
- package/dist/types/tools/irc.d.ts +6 -0
- package/dist/types/tools/job-widget.d.ts +226 -0
- package/dist/types/tools/output-meta.d.ts +1 -1
- package/dist/types/tools/streaming-output.d.ts +1 -0
- package/dist/types/tools/task.d.ts +12 -0
- package/dist/types/tools/wait.d.ts +5 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +12 -1
- package/src/chat/skill-message.ts +19 -21
- package/src/components/editor.ts +17 -7
- package/src/components/image.ts +5 -4
- package/src/components/sparkline.ts +89 -0
- package/src/index.ts +1 -0
- package/src/native/describe.ts +18 -1
- package/src/native/tone.ts +22 -0
- package/src/overlays/agent-hub.ts +14 -10
- package/src/overlays/agent-transcript-viewer.ts +2 -2
- package/src/overlays/approval-dialog.ts +59 -13
- package/src/overlays/jobs-panel.ts +10 -0
- package/src/overlays/model-browser.ts +3 -0
- package/src/prompt/attachment-chips.ts +1 -1
- package/src/sixel.ts +185 -0
- package/src/status-line/component.ts +19 -2
- package/src/status-line/host.ts +3 -0
- package/src/terminal-capabilities.ts +4 -1
- package/src/theme/symbols.ts +5 -3
- package/src/tools/irc.ts +6 -0
- package/src/tools/job-widget.ts +1036 -0
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/streaming-output.ts +11 -9
- package/src/tools/task.ts +25 -1
- package/src/tools/wait.ts +141 -85
- package/src/tui.ts +45 -5
|
@@ -13,8 +13,8 @@ import { fileHyperlink } from "../render";
|
|
|
13
13
|
import { collapseSkillTokens, skillChipLabel, skillChipStyle, skillToken } from "../prompt/composer-attachments";
|
|
14
14
|
import { type UserBubbleOptions, UserMessageComponent, userBubbleColor } from "./user-message";
|
|
15
15
|
import type { TspSpan } from "@oh-my-pi/pi-wire";
|
|
16
|
-
import { card, md, node, span
|
|
17
|
-
import { type NativeChild, type NativeNode, type NativeUiEvent
|
|
16
|
+
import { card, md, node, span } from "../native/describe";
|
|
17
|
+
import { type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
|
|
18
18
|
import { Memo } from "../native/memo";
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -63,15 +63,13 @@ export class SkillMessageComponent extends Container {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
66
|
-
|
|
67
|
-
if (expanded !== undefined) this.setExpanded(expanded);
|
|
66
|
+
if (event.type === "toggle" && event.key === "prompt") this.setExpanded(!event.collapsed);
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
|
-
* A user-toned card (role `omp.skill`): the skill chip
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* be large.
|
|
70
|
+
* A user-toned card (role `omp.skill`): the skill chip and prompt size in
|
|
71
|
+
* the head, the user's draft always visible, and only the loaded skill
|
|
72
|
+
* prompt behind a disclosure.
|
|
75
73
|
*/
|
|
76
74
|
override describe(): NativeNode {
|
|
77
75
|
return this.#native.get([this.#expanded], () => {
|
|
@@ -100,20 +98,20 @@ export class SkillMessageComponent extends Container {
|
|
|
100
98
|
const children: NativeChild[] = [];
|
|
101
99
|
if (draft) children.push(md(draft));
|
|
102
100
|
const promptText = this.#expanded ? this.#extractText() : "";
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
children.length > 0 ? children : [text([span(label, "muted")])],
|
|
101
|
+
children.push(
|
|
102
|
+
node(
|
|
103
|
+
"section",
|
|
104
|
+
{
|
|
105
|
+
head: [span("prompt", "muted")],
|
|
106
|
+
role: "omp.skill.prompt",
|
|
107
|
+
collapsible: true,
|
|
108
|
+
collapsed: !this.#expanded,
|
|
109
|
+
},
|
|
110
|
+
promptText ? [md(promptText)] : [],
|
|
111
|
+
"prompt",
|
|
112
|
+
),
|
|
116
113
|
);
|
|
114
|
+
return card({ role: "omp.skill", tone: "user", head }, children);
|
|
117
115
|
});
|
|
118
116
|
}
|
|
119
117
|
|
package/src/components/editor.ts
CHANGED
|
@@ -704,6 +704,8 @@ export class Editor implements Component, Focusable {
|
|
|
704
704
|
| { line: number; startCol: number; endCol: number; original: string; cursorOffset: number }
|
|
705
705
|
| undefined;
|
|
706
706
|
#autocompletePrefix: string = "";
|
|
707
|
+
/** False while the popup's untouched top row is a description-only slash hit ({@link AutocompleteItem.descriptionMatch}): Enter then submits the typed text. */
|
|
708
|
+
#enterAcceptsSelection = true;
|
|
707
709
|
#autocompleteRequestId: number = 0;
|
|
708
710
|
#autocompletePendingRequest: AutocompleteRequest | undefined;
|
|
709
711
|
#autocompleteRequestRunning = false;
|
|
@@ -1916,6 +1918,7 @@ export class Editor implements Component, Focusable {
|
|
|
1916
1918
|
this.onAutocompleteUpdate?.();
|
|
1917
1919
|
} else {
|
|
1918
1920
|
this.#autocompleteList.handleInput(data);
|
|
1921
|
+
this.#enterAcceptsSelection = true;
|
|
1919
1922
|
this.onAutocompleteUpdate?.();
|
|
1920
1923
|
return;
|
|
1921
1924
|
}
|
|
@@ -1960,8 +1963,11 @@ export class Editor implements Component, Focusable {
|
|
|
1960
1963
|
// Check for stale autocomplete state while an async refresh is pending
|
|
1961
1964
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1962
1965
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1963
|
-
if (
|
|
1964
|
-
|
|
1966
|
+
if (
|
|
1967
|
+
!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
|
|
1968
|
+
!this.#enterAcceptsSelection
|
|
1969
|
+
) {
|
|
1970
|
+
// Stale, or a description-only hit the user never selected: submit the typed text.
|
|
1965
1971
|
this.#cancelAutocomplete();
|
|
1966
1972
|
} else {
|
|
1967
1973
|
if (selected && this.#autocompleteProvider) {
|
|
@@ -1993,10 +1999,12 @@ export class Editor implements Component, Focusable {
|
|
|
1993
1999
|
if (
|
|
1994
2000
|
!selected ||
|
|
1995
2001
|
!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
|
|
1996
|
-
this.#selectedSlashArgumentIsAlreadyTyped(selected)
|
|
2002
|
+
this.#selectedSlashArgumentIsAlreadyTyped(selected) ||
|
|
2003
|
+
!this.#enterAcceptsSelection
|
|
1997
2004
|
) {
|
|
1998
|
-
// Stale,
|
|
1999
|
-
// typed slash-command argument (`/mcp list` + Enter)
|
|
2005
|
+
// Stale, accepting would only append whitespace to an already fully
|
|
2006
|
+
// typed slash-command argument (`/mcp list` + Enter), or the row is a
|
|
2007
|
+
// description-only hit the user never selected: cancel and fall
|
|
2000
2008
|
// through to normal submission instead of swallowing the keypress.
|
|
2001
2009
|
this.#cancelAutocomplete();
|
|
2002
2010
|
} else {
|
|
@@ -2140,9 +2148,10 @@ export class Editor implements Component, Focusable {
|
|
|
2140
2148
|
) {
|
|
2141
2149
|
const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
|
|
2142
2150
|
// The collapsed `/skill:` namespace row is not a runnable command; Enter submits
|
|
2143
|
-
// immediately after applying, so only a real command may be completed here
|
|
2151
|
+
// immediately after applying, so only a real command may be completed here, and
|
|
2152
|
+
// only one whose name matches: a description-only hit would run an unrelated command.
|
|
2144
2153
|
const selected = syncResult?.items.find(item => item.value !== SKILL_NAMESPACE);
|
|
2145
|
-
if (syncResult && selected) {
|
|
2154
|
+
if (syncResult && selected && !selected.descriptionMatch) {
|
|
2146
2155
|
// Invalidate any pending async autocomplete so its stale results are discarded
|
|
2147
2156
|
this.#autocompleteRequestId += 1;
|
|
2148
2157
|
// Apply the best match and submit the completed command
|
|
@@ -4871,6 +4880,7 @@ export class Editor implements Component, Focusable {
|
|
|
4871
4880
|
): void {
|
|
4872
4881
|
this.#autocompletePrefix = suggestions.prefix;
|
|
4873
4882
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
4883
|
+
this.#enterAcceptsSelection = !suggestions.items[0]?.descriptionMatch;
|
|
4874
4884
|
this.#autocompleteState = state;
|
|
4875
4885
|
this.onAutocompleteUpdate?.();
|
|
4876
4886
|
}
|
package/src/components/image.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { encodeSixelAsync } from "@oh-my-pi/pi-natives";
|
|
2
2
|
import { getKittyGraphics } from "../kitty-graphics";
|
|
3
|
+
import { ensureSixelRasterWidth } from "../sixel";
|
|
3
4
|
import {
|
|
4
5
|
encodeSixelNow,
|
|
5
6
|
getCellDimensions,
|
|
@@ -736,7 +737,7 @@ export class Image implements Component {
|
|
|
736
737
|
const request: SixelEncode = { widthPx, heightPx, backgroundRgb };
|
|
737
738
|
this.#sixel = request;
|
|
738
739
|
const settle = (sequence: string | null): void => {
|
|
739
|
-
request.sequence = sequence;
|
|
740
|
+
request.sequence = sequence === null ? null : ensureSixelRasterWidth(sequence);
|
|
740
741
|
if (this.#sixel !== request || getTerminalBackgroundColor() !== backgroundRgb) return;
|
|
741
742
|
this.invalidate();
|
|
742
743
|
if (this.#options.requestRender) this.#options.requestRender();
|
|
@@ -845,9 +846,9 @@ export class Image implements Component {
|
|
|
845
846
|
// height. First (rows-1) lines are empty (TUI clears them); the last
|
|
846
847
|
// saves the final-row cursor, moves up to the image origin, emits the
|
|
847
848
|
// image sequence, then restores the final-row cursor. When the block
|
|
848
|
-
// straddles the viewport top, the renderer
|
|
849
|
-
//
|
|
850
|
-
// registered below.
|
|
849
|
+
// straddles the viewport top, the renderer crops the source raster
|
|
850
|
+
// for SIXEL or rewrites the Kitty placement's visible source slice
|
|
851
|
+
// from the geometry registered below.
|
|
851
852
|
if (this.#imageId != null && this.#budget !== undefined) {
|
|
852
853
|
this.#budget.registerPlacementGeometry(
|
|
853
854
|
this.#imageId,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-row block sparklines (`▁▂▃▅▇█`) for ANSI renderers: the usage history
|
|
3
|
+
* in `omp usage`, a job widget's series in the `wait` card and status line.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Block glyphs from lowest to highest. */
|
|
7
|
+
export const SPARK_LEVELS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
|
|
8
|
+
|
|
9
|
+
/** How {@link renderSparkline} maps values to glyphs and styles them. */
|
|
10
|
+
export interface SparklineOptions {
|
|
11
|
+
/** Domain low end; values at or below it draw the lowest block. Default 0. */
|
|
12
|
+
readonly min?: number;
|
|
13
|
+
/** Domain high end; values at or above it draw the full block. Default the largest value. */
|
|
14
|
+
readonly max?: number;
|
|
15
|
+
/** Styles one glyph; `fraction` is the value's 0–1 position in the domain. */
|
|
16
|
+
readonly style?: (glyph: string, value: number, fraction: number) => string;
|
|
17
|
+
/** What a missing value draws, already styled. Default a space. */
|
|
18
|
+
readonly gap?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The block glyph for a 0–1 fraction of the domain. */
|
|
22
|
+
export function sparkGlyph(fraction: number): string {
|
|
23
|
+
const clamped = Number.isFinite(fraction) ? Math.min(Math.max(fraction, 0), 1) : 0;
|
|
24
|
+
return SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.floor(clamped * SPARK_LEVELS.length))]!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One cell per value, oldest first; `undefined` (and non-finite) values draw the gap. */
|
|
28
|
+
export function renderSparkline(values: readonly (number | undefined)[], options: SparklineOptions = {}): string {
|
|
29
|
+
const min = options.min ?? 0;
|
|
30
|
+
let max = options.max;
|
|
31
|
+
if (max === undefined) {
|
|
32
|
+
max = min;
|
|
33
|
+
for (const value of values) if (value !== undefined && Number.isFinite(value) && value > max) max = value;
|
|
34
|
+
}
|
|
35
|
+
const span = max - min;
|
|
36
|
+
const gap = options.gap ?? " ";
|
|
37
|
+
let out = "";
|
|
38
|
+
for (const value of values) {
|
|
39
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
40
|
+
out += gap;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const fraction = span > 0 ? (value - min) / span : value > min ? 1 : 0;
|
|
44
|
+
const glyph = sparkGlyph(fraction);
|
|
45
|
+
out += options.style ? options.style(glyph, value, Math.min(Math.max(fraction, 0), 1)) : glyph;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `values` squeezed into at most `cells` points by equal-count bucket means,
|
|
52
|
+
* newest bucket last; shorter series come back unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export function fitSparkValues(values: readonly number[], cells: number): readonly number[] {
|
|
55
|
+
const count = Math.max(0, Math.floor(cells));
|
|
56
|
+
if (values.length <= count) return values;
|
|
57
|
+
const out: number[] = [];
|
|
58
|
+
for (let bucket = 0; bucket < count; bucket++) {
|
|
59
|
+
const from = Math.floor((bucket * values.length) / count);
|
|
60
|
+
const to = Math.max(from + 1, Math.floor(((bucket + 1) * values.length) / count));
|
|
61
|
+
let sum = 0;
|
|
62
|
+
for (let i = from; i < to; i++) sum += values[i]!;
|
|
63
|
+
out.push(sum / (to - from));
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The largest value per time bucket over `[sinceMs, nowMs]`, `cells` buckets
|
|
70
|
+
* wide; buckets without a point are `undefined` (a gap in the sparkline).
|
|
71
|
+
*/
|
|
72
|
+
export function peakBuckets(
|
|
73
|
+
points: Iterable<{ readonly at: number; readonly value: number }>,
|
|
74
|
+
sinceMs: number,
|
|
75
|
+
nowMs: number,
|
|
76
|
+
cells: number,
|
|
77
|
+
): (number | undefined)[] {
|
|
78
|
+
const span = Math.max(1, nowMs - sinceMs);
|
|
79
|
+
const count = Math.max(0, Math.floor(cells));
|
|
80
|
+
const buckets: (number | undefined)[] = Array.from({ length: count }, () => undefined);
|
|
81
|
+
if (count === 0) return buckets;
|
|
82
|
+
for (const point of points) {
|
|
83
|
+
const offset = Math.floor(((point.at - sinceMs) / span) * count);
|
|
84
|
+
const index = Math.min(count - 1, Math.max(0, offset));
|
|
85
|
+
const prev = buckets[index];
|
|
86
|
+
buckets[index] = prev === undefined ? point.value : Math.max(prev, point.value);
|
|
87
|
+
}
|
|
88
|
+
return buckets;
|
|
89
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export * from "./components/section";
|
|
|
28
28
|
export * from "./components/select-list";
|
|
29
29
|
export * from "./components/settings-list";
|
|
30
30
|
export * from "./components/spacer";
|
|
31
|
+
export * from "./components/sparkline";
|
|
31
32
|
export * from "./components/tab-bar";
|
|
32
33
|
export * from "./components/table";
|
|
33
34
|
export * from "./components/text";
|
package/src/native/describe.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ]);
|
|
10
10
|
* }
|
|
11
11
|
*/
|
|
12
|
-
import type { TspKind, TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
|
|
12
|
+
import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
|
|
13
13
|
import type { NativeChild, NativeNode } from "./node";
|
|
14
14
|
|
|
15
15
|
/** A node of any kind. */
|
|
@@ -76,6 +76,23 @@ export function withHidden(described: NativeNode, hidden: boolean): NativeNode {
|
|
|
76
76
|
return node(described.k, { ...described.p, hidden: true } as TspProps, described.c, described.key);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A described tree as the node tree a prop carries (a `hovercard`): plain
|
|
81
|
+
* nodes only, since nothing reconciles inside a prop; component children
|
|
82
|
+
* (which describe themselves per frame) are left out.
|
|
83
|
+
*/
|
|
84
|
+
export function propNode(described: NativeNode): TspPropNode {
|
|
85
|
+
const out: { k: TspKind; p?: TspProps; c?: TspPropNode[]; key?: string } = { k: described.k };
|
|
86
|
+
if (described.p !== undefined) out.p = described.p;
|
|
87
|
+
if (described.c !== undefined) {
|
|
88
|
+
const children: TspPropNode[] = [];
|
|
89
|
+
for (const child of described.c) if ("k" in child) children.push(propNode(child));
|
|
90
|
+
out.c = children;
|
|
91
|
+
}
|
|
92
|
+
if (described.key !== undefined) out.key = described.key;
|
|
93
|
+
return out as TspPropNode;
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
/** Stable node key for an arbitrary identity string; keys join into `/` keypaths, so identities are hashed. */
|
|
80
97
|
export function stableKey(identity: string): string {
|
|
81
98
|
return Bun.hash(identity).toString(36);
|
package/src/native/tone.ts
CHANGED
|
@@ -50,6 +50,28 @@ export function backgroundChrome(bg: ThemeBg | undefined): BackgroundChrome {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** The omp theme colour an ANSI renderer draws a TSP tone with (the inverse of {@link colorTone}). */
|
|
54
|
+
export function toneColor(tone: TspTone): ThemeColor {
|
|
55
|
+
switch (tone) {
|
|
56
|
+
case "neutral":
|
|
57
|
+
return "text";
|
|
58
|
+
case "accent":
|
|
59
|
+
case "info":
|
|
60
|
+
return "accent";
|
|
61
|
+
case "success":
|
|
62
|
+
return "success";
|
|
63
|
+
case "warning":
|
|
64
|
+
case "pending":
|
|
65
|
+
return "warning";
|
|
66
|
+
case "error":
|
|
67
|
+
return "error";
|
|
68
|
+
case "muted":
|
|
69
|
+
return "muted";
|
|
70
|
+
case "user":
|
|
71
|
+
return "customMessageLabel";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
/**
|
|
54
76
|
* Tone of an omp colour used as a severity or border colour; undefined for
|
|
55
77
|
* colours that carry no tone (callers pick their own fallback).
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* - Table view: every registered agent except Main (Main IS the ambient
|
|
6
6
|
* chat), live from the global AgentHubRegistry — status, unread irc count,
|
|
7
7
|
* current/last task, last activity. Navigate with keys, wheel, hover, and
|
|
8
|
-
* click; `r` revives a parked agent, `x` aborts + releases one.
|
|
8
|
+
* click; `r` revives a parked or killed agent, `x` aborts + releases one.
|
|
9
9
|
* - Chat view: per-agent transcript (incremental session-file tail, absorbed
|
|
10
10
|
* from the old session observer overlay) plus an input line. Submitting
|
|
11
|
-
* revives a parked agent, then prompts/steers it; the message
|
|
12
|
-
* agent's persisted history via the normal prompt path.
|
|
11
|
+
* revives a parked or killed agent, then prompts/steers it; the message
|
|
12
|
+
* lands in the agent's persisted history via the normal prompt path.
|
|
13
13
|
*
|
|
14
14
|
* Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
|
|
15
15
|
*/
|
|
@@ -894,7 +894,11 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
894
894
|
id: "revive",
|
|
895
895
|
label: "Revive",
|
|
896
896
|
keys: ["r"],
|
|
897
|
-
disabled:
|
|
897
|
+
disabled:
|
|
898
|
+
readOnly ??
|
|
899
|
+
(selected?.status === "parked" || selected?.status === "aborted"
|
|
900
|
+
? undefined
|
|
901
|
+
: "Only parked or killed agents can be revived"),
|
|
898
902
|
},
|
|
899
903
|
{ id: "kill", label: "Kill", keys: ["x"], danger: true, disabled: readOnly ?? (selected ? undefined : true) },
|
|
900
904
|
{ id: "view", label: "By parent", keys: ["t"], on: this.#viewMode === "tree" },
|
|
@@ -2498,15 +2502,15 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
2498
2502
|
#activateAgent(ref: TRecord): void {
|
|
2499
2503
|
this.#notice = undefined;
|
|
2500
2504
|
const focusAgent = this.#focusAgent;
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
if (ref.kind === "advisor" ||
|
|
2505
|
+
// Advisor refs are read-only transcripts with no revivable session; open
|
|
2506
|
+
// the in-hub viewer instead of failing ensureLive.
|
|
2507
|
+
if (ref.kind === "advisor" || this.#remote || !focusAgent) {
|
|
2504
2508
|
this.openChat(ref.id);
|
|
2505
2509
|
return;
|
|
2506
2510
|
}
|
|
2507
2511
|
void (async () => {
|
|
2508
2512
|
try {
|
|
2509
|
-
await focusAgent(ref.id); // ensureLive inside revives parked
|
|
2513
|
+
await focusAgent(ref.id); // ensureLive inside revives parked and killed agents
|
|
2510
2514
|
this.#onDone();
|
|
2511
2515
|
} catch (error) {
|
|
2512
2516
|
this.#notice = error instanceof Error ? error.message : String(error);
|
|
@@ -2523,8 +2527,8 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
2523
2527
|
this.#requestRender();
|
|
2524
2528
|
return;
|
|
2525
2529
|
}
|
|
2526
|
-
if (ref.status !== "parked") {
|
|
2527
|
-
this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
|
|
2530
|
+
if (ref.status !== "parked" && ref.status !== "aborted") {
|
|
2531
|
+
this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked or killed agents can be revived.`;
|
|
2528
2532
|
this.#requestRender();
|
|
2529
2533
|
return;
|
|
2530
2534
|
}
|
|
@@ -235,10 +235,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
235
235
|
this.#pollTimer.unref?.();
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/** Advisor
|
|
238
|
+
/** Advisor transcripts are read-only; every other agent revives on send. */
|
|
239
239
|
get #sendable(): boolean {
|
|
240
240
|
const ref = this.#deps.registry.get(this.#deps.agentId);
|
|
241
|
-
if (!ref || ref.kind === "advisor"
|
|
241
|
+
if (!ref || ref.kind === "advisor") return false;
|
|
242
242
|
return Boolean(this.#deps.remote || this.#deps.lifecycle);
|
|
243
243
|
}
|
|
244
244
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool-approval prompt in the composer's place, presented like the ask
|
|
3
3
|
* dialog: the approval title and its details over a list of choices —
|
|
4
|
-
*
|
|
4
|
+
* optional grants (led by a toggle that keeps them across sessions), Approve,
|
|
5
|
+
* an optional "approve for this session", and Deny — and an action
|
|
5
6
|
* bar. The arrows move between the choices and Enter takes the highlighted
|
|
6
7
|
* one; `app.approval.approve` (Ctrl+Enter by default) approves this call
|
|
7
8
|
* outright and Esc denies. Enter and the approve chord only arm
|
|
@@ -45,7 +46,8 @@ export const APPROVE_ARM_MS = 500;
|
|
|
45
46
|
/** The Deny row while a composer draft holds Enter: sending it denies the call and steers the turn. */
|
|
46
47
|
const DENY_AND_SEND_LABEL = "Deny and send message";
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
/** `grant<N>` takes the Nth of {@link ApprovalDialogOptions.grants}; `remember` toggles their lifetime. */
|
|
50
|
+
type ApprovalAnswer = "once" | "session" | "deny" | "remember" | `grant${number}`;
|
|
49
51
|
|
|
50
52
|
interface ApprovalChoice {
|
|
51
53
|
readonly label: string;
|
|
@@ -57,6 +59,14 @@ interface ApprovalDialogCallbacks {
|
|
|
57
59
|
onDeny(): void;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** A choice that approves the call and remembers more than it: a folder, or the exact target. */
|
|
63
|
+
export interface ApprovalDialogGrant {
|
|
64
|
+
/** Label while the grant is remembered for this session. */
|
|
65
|
+
readonly label: string;
|
|
66
|
+
/** Label once the remember toggle keeps it across sessions. */
|
|
67
|
+
readonly alwaysLabel: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/** The composer mounted beneath the dialog: it receives every key the dialog does not own. */
|
|
61
71
|
export interface ApprovalDialogDraft {
|
|
62
72
|
/** True while the composer holds a draft — text, attachments, or a paste still landing: keys then edit and send it. */
|
|
@@ -80,6 +90,12 @@ interface ApprovalDialogOptions {
|
|
|
80
90
|
draft?: ApprovalDialogDraft;
|
|
81
91
|
/** Offer to approve this and later like calls for the rest of the session, as the row between Approve and Deny. */
|
|
82
92
|
session?: { label: string; onApprove(): void };
|
|
93
|
+
/**
|
|
94
|
+
* Grants to offer above Approve, led by a toggle that keeps them across sessions; Approve then
|
|
95
|
+
* reads "Approve Once". `onGrant` gets the chosen grant and whether the toggle was on.
|
|
96
|
+
*/
|
|
97
|
+
grants?: readonly ApprovalDialogGrant[];
|
|
98
|
+
onGrant?(grant: ApprovalDialogGrant, always: boolean): void;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
/** A prompt's detail rows: every line after the title, trailing blanks dropped. */
|
|
@@ -100,9 +116,11 @@ export class ApprovalDialogComponent implements Component {
|
|
|
100
116
|
readonly #footer = new PanelRows();
|
|
101
117
|
#scrollOffset = 0;
|
|
102
118
|
#bodyRows = MAX_DETAIL_ROWS;
|
|
103
|
-
/** In list order
|
|
104
|
-
|
|
119
|
+
/** In list order: the remember toggle and grants (when offered), Approve (highlighted on mount), the session row, Deny last. */
|
|
120
|
+
#choices: readonly ApprovalChoice[];
|
|
105
121
|
#choice = 0;
|
|
122
|
+
/** The remember toggle: grants are kept across sessions while it is on. */
|
|
123
|
+
#rememberAlways = false;
|
|
106
124
|
#closed = false;
|
|
107
125
|
#native: NativeNode | undefined;
|
|
108
126
|
/** Whether a draft held the keys when {@link #native} was described: it moves the highlight. */
|
|
@@ -122,11 +140,8 @@ export class ApprovalDialogComponent implements Component {
|
|
|
122
140
|
this.#summary = promptDetails(lines);
|
|
123
141
|
this.#full =
|
|
124
142
|
options.fullPrompt === undefined ? undefined : promptDetails(sanitizeDisplayLines(options.fullPrompt));
|
|
125
|
-
this.#choices =
|
|
126
|
-
|
|
127
|
-
...(options.session ? [{ label: options.session.label, answer: "session" as const }] : []),
|
|
128
|
-
{ label: "Deny", answer: "deny" },
|
|
129
|
-
];
|
|
143
|
+
this.#choices = this.#buildChoices();
|
|
144
|
+
this.#choice = this.#choices.findIndex(choice => choice.answer === "once");
|
|
130
145
|
this.#panel = new OverlayPanel(title, "omp.overlay.approval");
|
|
131
146
|
this.#footer.setHeight(1);
|
|
132
147
|
this.#panel.addChild(this.#body);
|
|
@@ -149,6 +164,33 @@ export class ApprovalDialogComponent implements Component {
|
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
#buildChoices(): ApprovalChoice[] {
|
|
168
|
+
const grants = this.options.grants ?? [];
|
|
169
|
+
const glyph = this.#rememberAlways ? theme.checkbox.checked : theme.checkbox.unchecked;
|
|
170
|
+
return [
|
|
171
|
+
...(grants.length > 0 ? [{ label: `${glyph} Remember across sessions`, answer: "remember" as const }] : []),
|
|
172
|
+
...grants.map((grant, index) => ({
|
|
173
|
+
label: this.#rememberAlways ? grant.alwaysLabel : grant.label,
|
|
174
|
+
answer: `grant${index}` as const,
|
|
175
|
+
})),
|
|
176
|
+
{ label: grants.length > 0 ? "Approve Once" : "Approve", answer: "once" },
|
|
177
|
+
...(this.options.session ? [{ label: this.options.session.label, answer: "session" as const }] : []),
|
|
178
|
+
{ label: "Deny", answer: "deny" },
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Run a choice: the remember toggle flips the grants' lifetime in place, every other choice answers. */
|
|
183
|
+
#take(answer: ApprovalAnswer): void {
|
|
184
|
+
if (answer !== "remember") {
|
|
185
|
+
this.#finish(answer);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.#rememberAlways = !this.#rememberAlways;
|
|
189
|
+
this.#choices = this.#buildChoices();
|
|
190
|
+
this.#native = undefined;
|
|
191
|
+
this.options.tui?.requestRender();
|
|
192
|
+
}
|
|
193
|
+
|
|
152
194
|
invalidate(): void {
|
|
153
195
|
this.#native = undefined;
|
|
154
196
|
this.#panel.invalidate();
|
|
@@ -191,7 +233,7 @@ export class ApprovalDialogComponent implements Component {
|
|
|
191
233
|
else if (matchesSelectPageUp(keyData)) this.#scroll(-page);
|
|
192
234
|
else if (matchesSelectPageDown(keyData)) this.#scroll(page);
|
|
193
235
|
else if (getKeybindings().matches(keyData, "tui.select.confirm")) {
|
|
194
|
-
if (performance.now() >= this.#armedAt) this.#
|
|
236
|
+
if (performance.now() >= this.#armedAt) this.#take(this.#choices[this.#choice]!.answer);
|
|
195
237
|
}
|
|
196
238
|
// Enter never reaches an empty composer: it would submit nothing, or interrupt the run.
|
|
197
239
|
else if (draft && !getKeybindings().matches(keyData, "tui.input.submit")) this.#editDraft(draft, keyData);
|
|
@@ -295,7 +337,7 @@ export class ApprovalDialogComponent implements Component {
|
|
|
295
337
|
tone: "error",
|
|
296
338
|
title: `Deny this call${cancelKey ? ` ${formatTooltipKey(cancelKey)}` : ""}`,
|
|
297
339
|
});
|
|
298
|
-
const approve = actionButton("Approve", "approve", {
|
|
340
|
+
const approve = actionButton(this.options.grants?.length ? "Approve Once" : "Approve", "approve", {
|
|
299
341
|
...(approveKey ? { keys: approveKey } : {}),
|
|
300
342
|
tone: "accent",
|
|
301
343
|
title: `Approve this call${approveKey ? ` ${formatTooltipKey(approveKey)}` : ""}`,
|
|
@@ -350,7 +392,7 @@ export class ApprovalDialogComponent implements Component {
|
|
|
350
392
|
}
|
|
351
393
|
if ((event.type !== "select" && event.type !== "activate") || leafKey(event.key) !== "choices") return;
|
|
352
394
|
const choice = this.#choices.find(candidate => candidate.answer === event.item);
|
|
353
|
-
if (choice) this.#
|
|
395
|
+
if (choice) this.#take(choice.answer);
|
|
354
396
|
}
|
|
355
397
|
|
|
356
398
|
/** Detail rows at `width`: one clipped row per detail, or wrapped rows once expanded. */
|
|
@@ -384,6 +426,10 @@ export class ApprovalDialogComponent implements Component {
|
|
|
384
426
|
this.#countdown?.dispose();
|
|
385
427
|
if (answer === "once") this.callbacks.onApprove();
|
|
386
428
|
else if (answer === "session") this.options.session?.onApprove();
|
|
387
|
-
else
|
|
429
|
+
else if (answer.startsWith("grant")) {
|
|
430
|
+
const grant = this.options.grants?.[Number(answer.slice("grant".length))];
|
|
431
|
+
if (grant) this.options.onGrant?.(grant, this.#rememberAlways);
|
|
432
|
+
else this.callbacks.onDeny();
|
|
433
|
+
} else this.callbacks.onDeny();
|
|
388
434
|
}
|
|
389
435
|
}
|
|
@@ -17,6 +17,7 @@ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "..
|
|
|
17
17
|
import { actionBar, actionButton, selectList } from "../native/overlay";
|
|
18
18
|
import { theme } from "../theme/theme";
|
|
19
19
|
import { buildJobTree, type JobTreeAgent, type JobTreeRow, jobTreeGuide } from "./job-tree";
|
|
20
|
+
import { describeJobWidget, type JobWidgetView, renderJobWidgetLines } from "../tools/job-widget";
|
|
20
21
|
|
|
21
22
|
/** One async job as the session snapshots it. */
|
|
22
23
|
export interface JobsPanelJob {
|
|
@@ -54,6 +55,8 @@ export interface JobsPanelDetail {
|
|
|
54
55
|
readonly output?: string;
|
|
55
56
|
/** Artifact holding the full output when `output` is cut. */
|
|
56
57
|
readonly artifactId?: string;
|
|
58
|
+
/** The job's live widget (frozen once it settles), drawn above the output. */
|
|
59
|
+
readonly widget?: JobWidgetView;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** The session behind a {@link JobsSheet}. */
|
|
@@ -149,6 +152,7 @@ export class JobsSheet implements Component {
|
|
|
149
152
|
detail?.pids.join(" "),
|
|
150
153
|
detail?.exitCode,
|
|
151
154
|
detail?.artifactId,
|
|
155
|
+
detail?.widget ? `${detail.widget.updatedAt}:${detail.widget.state}` : "",
|
|
152
156
|
jobs.some(job => job.status === "running") ? Math.floor(now / 1000) : "",
|
|
153
157
|
].join("|");
|
|
154
158
|
const output = detail?.output;
|
|
@@ -205,6 +209,11 @@ export class JobsSheet implements Component {
|
|
|
205
209
|
detail.artifactId && `artifact://${detail.artifactId}`,
|
|
206
210
|
]);
|
|
207
211
|
if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
|
|
212
|
+
if (detail.widget) {
|
|
213
|
+
for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
|
|
214
|
+
lines.push(` ${line}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
208
217
|
for (const line of this.#tailLines(selected.id, detail.output ?? "")) {
|
|
209
218
|
if (line) lines.push(truncateToWidth(` ${line}`, width));
|
|
210
219
|
}
|
|
@@ -413,6 +422,7 @@ function describeDetail(
|
|
|
413
422
|
? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "omp.jobs.command" })
|
|
414
423
|
: text(job.label, { role: "omp.jobs.title", wrap: "word" }),
|
|
415
424
|
facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
|
|
425
|
+
detail?.widget && node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
|
|
416
426
|
node(
|
|
417
427
|
"col",
|
|
418
428
|
{ role: "omp.jobs.out", gap: "sm" },
|
|
@@ -63,6 +63,7 @@ export type ModelRole =
|
|
|
63
63
|
| "memory"
|
|
64
64
|
| "task"
|
|
65
65
|
| "advisor"
|
|
66
|
+
| "widget"
|
|
66
67
|
| "image"
|
|
67
68
|
| "web"
|
|
68
69
|
| "speech"
|
|
@@ -79,6 +80,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
|
79
80
|
"memory",
|
|
80
81
|
"task",
|
|
81
82
|
"advisor",
|
|
83
|
+
"widget",
|
|
82
84
|
"image",
|
|
83
85
|
"web",
|
|
84
86
|
"speech",
|
|
@@ -96,6 +98,7 @@ export const CHAT_MODEL_ROLE_IDS: ModelRole[] = [
|
|
|
96
98
|
"memory",
|
|
97
99
|
"task",
|
|
98
100
|
"advisor",
|
|
101
|
+
"widget",
|
|
99
102
|
];
|
|
100
103
|
export const KIND_ROLE_IDS: ModelRole[] = ["image", "web", "speech", "dictation", "judge"];
|
|
101
104
|
|
|
@@ -93,7 +93,7 @@ export class AttachmentChipsBand implements Component {
|
|
|
93
93
|
nativeImageNode(Buffer.from(image.data, "base64"), image.mimeType);
|
|
94
94
|
images.set(image, native);
|
|
95
95
|
content = node("image", {
|
|
96
|
-
...native.p,
|
|
96
|
+
...(native.k === "image" ? native.p : undefined),
|
|
97
97
|
alt: title,
|
|
98
98
|
w: dims?.width,
|
|
99
99
|
h: dims?.height,
|