@oh-my-pi/pi-tui 18.13.0 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +36 -32
  2. package/dist/types/chrome/context-map.d.ts +15 -0
  3. package/dist/types/chrome/context-presentation.d.ts +48 -0
  4. package/dist/types/chrome/context-view-model.d.ts +77 -0
  5. package/dist/types/overlays/advisor-config.d.ts +6 -0
  6. package/dist/types/overlays/ask-dialog.d.ts +12 -3
  7. package/dist/types/overlays/context-viewer-format.d.ts +42 -0
  8. package/dist/types/overlays/context-viewer-overview.d.ts +70 -0
  9. package/dist/types/overlays/context-viewer.d.ts +25 -0
  10. package/dist/types/overlays/hook-input.d.ts +1 -0
  11. package/dist/types/overlays/pause-screen.d.ts +13 -3
  12. package/dist/types/overlays/running-subagent-badge.d.ts +7 -1
  13. package/dist/types/status-line/component.d.ts +2 -2
  14. package/dist/types/status-line/context-usage.d.ts +15 -30
  15. package/dist/types/theme/symbols.d.ts +1 -1
  16. package/dist/types/tools/ask.d.ts +4 -0
  17. package/dist/types/tools/fetch.d.ts +1 -0
  18. package/dist/types/tools/github.d.ts +3 -0
  19. package/dist/types/tools/read.d.ts +3 -0
  20. package/dist/types/tools/wait.d.ts +6 -0
  21. package/dist/types/tui.d.ts +2 -0
  22. package/package.json +9 -9
  23. package/src/chrome/context-map.ts +62 -0
  24. package/src/chrome/context-presentation.ts +26 -0
  25. package/src/chrome/context-view-model.ts +73 -0
  26. package/src/components/editor.ts +23 -0
  27. package/src/components/markdown.ts +14 -2
  28. package/src/overlays/advisor-config.ts +9 -2
  29. package/src/overlays/ask-dialog.ts +122 -35
  30. package/src/overlays/context-viewer-format.ts +145 -0
  31. package/src/overlays/context-viewer-overview.ts +381 -0
  32. package/src/overlays/context-viewer.ts +728 -0
  33. package/src/overlays/hook-input.ts +2 -0
  34. package/src/overlays/pause-screen.ts +166 -37
  35. package/src/overlays/running-subagent-badge.ts +12 -1
  36. package/src/render/output-pane.ts +3 -3
  37. package/src/status-line/component.ts +6 -4
  38. package/src/status-line/context-usage.ts +56 -580
  39. package/src/theme/symbols.ts +16 -0
  40. package/src/tools/ask.ts +4 -0
  41. package/src/tools/bash.ts +5 -23
  42. package/src/tools/fetch.ts +1 -0
  43. package/src/tools/github.ts +23 -3
  44. package/src/tools/read.ts +3 -1
  45. package/src/tools/wait.ts +6 -0
  46. package/src/tui.ts +23 -3
@@ -21,6 +21,7 @@ export interface HookInputOptions {
21
21
  tui?: TUI;
22
22
  timeout?: number;
23
23
  onTimeout?: () => void;
24
+ secret?: boolean;
24
25
  }
25
26
 
26
27
  /** Terminal-only key legend under the field; a native terminal puts the keys in the buttons' tooltips. */
@@ -84,6 +85,7 @@ export class HookInputComponent extends OverlayPanel {
84
85
 
85
86
  this.#field = new TextFormField({
86
87
  theme: formTheme,
88
+ secret: opts?.secret ?? false,
87
89
  empty: "submit",
88
90
  onSubmit: value => this.#onSubmitCallback(value),
89
91
  onCancel: () => this.#onCancelCallback(),
@@ -14,7 +14,7 @@
14
14
  import { agentPauseGate } from "@oh-my-pi/pi-agent-core";
15
15
  import { type Component, matchesKey, type OverlayFocusOwner, type OverlayHandle, type OverlayOptions } from "../index";
16
16
  import { formatCoarseDuration } from "../chrome/format";
17
- import { centerLine } from "../utils";
17
+ import { centerLine, visibleWidth } from "../utils";
18
18
  import { theme } from "../theme/theme";
19
19
  import { matchesAppInterrupt } from "../keybinding-matchers";
20
20
  import { formatKeyHint, formatTooltipKey } from "../app-keybindings";
@@ -24,6 +24,17 @@ import { col, keyed, node, row, span, text } from "../native/describe";
24
24
  import { actionBar, actionButton } from "../native/overlay";
25
25
  import { isNativeRendering } from "../native/state";
26
26
 
27
+ /** Outstanding turn and job counts supplied by the pause-screen host. */
28
+ export interface PauseWorkStatus {
29
+ unsettledTurns: number;
30
+ unfinishedJobs: number;
31
+ }
32
+
33
+ /** Optional live work source for the pause overlay. */
34
+ export interface PauseScreenOptions {
35
+ getWorkStatus?: () => PauseWorkStatus;
36
+ }
37
+
27
38
  /**
28
39
  * Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
29
40
  * can exercise the full engage → hold → release lifecycle without a real TUI.
@@ -39,9 +50,10 @@ export interface PauseScreenHost {
39
50
  readonly sessionName?: string;
40
51
  }
41
52
 
42
- /** Refresh cadence for the live "paused for" clock. */
43
- const TICK_MS = 1_000;
53
+ const EMPTY_WORK_STATUS: PauseWorkStatus = { unsettledTurns: 0, unfinishedJobs: 0 };
44
54
 
55
+ /** Refresh cadence for the live hold clock and work-status snapshot. */
56
+ const TICK_MS = 1_000;
45
57
  /** Pause-bar glyph geometry (rows × columns of full blocks per bar). */
46
58
  const BAR_ROWS = 7;
47
59
  const BAR_WIDTH = 5;
@@ -49,7 +61,7 @@ const BAR_GAP = 4;
49
61
 
50
62
  /** Below either bound the full scene cannot breathe; drop to the compact card. */
51
63
  const MIN_FULL_WIDTH = 64;
52
- const MIN_FULL_HEIGHT = 18;
64
+ const MIN_FULL_HEIGHT = 23;
53
65
 
54
66
  const TITLE = "P A U S E D";
55
67
  const BODY_LINES = [
@@ -79,51 +91,110 @@ function formatClock(ms: number): string {
79
91
  * Paint the pause scene as exactly `height` rows, vertically centered.
80
92
  * Exported for tests.
81
93
  */
82
- export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
83
- const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
94
+ export function renderPauseScreen(
95
+ width: number,
96
+ height: number,
97
+ elapsedMs: number,
98
+ sessionName?: string,
99
+ workStatus: PauseWorkStatus = EMPTY_WORK_STATUS,
100
+ ): string[] {
101
+ const screenWidth = Math.max(1, Math.floor(width));
102
+ const screenHeight = Math.max(1, Math.floor(height));
103
+ const compact = screenWidth < MIN_FULL_WIDTH || screenHeight < MIN_FULL_HEIGHT;
104
+ const outstanding = workStatus.unsettledTurns > 0 || workStatus.unfinishedJobs > 0;
105
+ const status = outstanding ? "Pausing — waiting for work to settle" : "Paused — ready to suspend";
106
+ const workCounts = [
107
+ `Unsettled turns: ${workStatus.unsettledTurns}`,
108
+ `Unfinished jobs: ${workStatus.unfinishedJobs}`,
109
+ ];
84
110
  const content: string[] = [];
85
111
  // Resume keys mirror PauseScreenComponent#handleInput: `app.interrupt` (raw Escape when unbound), Enter, Space.
86
112
  const esc = interruptKey();
87
113
 
88
114
  if (compact) {
89
- if (sessionName) {
90
- content.push(centerLine(theme.bold(sessionName), width).trimEnd());
91
- content.push("");
115
+ const sessionLine = sessionName
116
+ ? centerLine(theme.bold(sessionName.replace(/\s+/g, " ").trim()), screenWidth).trimEnd()
117
+ : undefined;
118
+ const titleLine = centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), screenWidth).trimEnd();
119
+ const clockLine = centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), screenWidth).trimEnd();
120
+ const statusLine = centerLine(theme.fg(outstanding ? "warning" : "success", status), screenWidth).trimEnd();
121
+ const resumeLine = centerLine(theme.fg("dim", `${esc} to resume`), screenWidth).trimEnd();
122
+ const fullContent: string[] = [];
123
+ if (sessionLine) fullContent.push(sessionLine);
124
+ fullContent.push(titleLine, "", clockLine, statusLine);
125
+ if (outstanding)
126
+ fullContent.push(...workCounts.map(line => centerLine(theme.fg("muted", line), screenWidth).trimEnd()));
127
+ fullContent.push(resumeLine);
128
+
129
+ if (screenHeight >= fullContent.length) {
130
+ content.push(...fullContent);
131
+ } else {
132
+ // Keep the live state and a way out on screen before the decorative card details.
133
+ if (screenHeight === 1) {
134
+ const counts = `T:${workStatus.unsettledTurns} J:${workStatus.unfinishedJobs}`;
135
+ const summary = outstanding ? `${counts} · ${esc} to resume` : `Paused · ${esc} to resume`;
136
+ const abbreviatedSummary = outstanding
137
+ ? `T${workStatus.unsettledTurns} J${workStatus.unfinishedJobs} ${esc} resume`
138
+ : `${esc} resume`;
139
+ const visibleSummary = visibleWidth(summary) > screenWidth ? abbreviatedSummary : summary;
140
+ content.push(
141
+ centerLine(theme.fg(outstanding ? "warning" : "success", visibleSummary), screenWidth).trimEnd(),
142
+ );
143
+ } else if (screenHeight === 2 && outstanding) {
144
+ const counts = `Pausing · T:${workStatus.unsettledTurns} J:${workStatus.unfinishedJobs}`;
145
+ content.push(centerLine(theme.fg("warning", counts), screenWidth).trimEnd(), resumeLine);
146
+ } else {
147
+ content.push(statusLine);
148
+ if (outstanding && screenHeight === 3) {
149
+ content.push(
150
+ centerLine(
151
+ theme.fg("muted", `Turns: ${workStatus.unsettledTurns} · Jobs: ${workStatus.unfinishedJobs}`),
152
+ screenWidth,
153
+ ).trimEnd(),
154
+ );
155
+ } else if (outstanding) {
156
+ content.push(...workCounts.map(line => centerLine(theme.fg("muted", line), screenWidth).trimEnd()));
157
+ }
158
+ content.push(resumeLine);
159
+ }
160
+
161
+ if (content.length < screenHeight && sessionLine) content.push(sessionLine);
162
+ if (content.length < screenHeight) content.push(titleLine);
163
+ if (content.length < screenHeight) content.push(clockLine);
92
164
  }
93
- content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width).trimEnd());
94
- content.push("");
95
- content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
96
- content.push(centerLine(theme.fg("dim", `${esc} to resume`), width).trimEnd());
97
165
  } else {
98
166
  if (sessionName) {
99
- content.push(centerLine(theme.bold(sessionName), width).trimEnd());
100
- content.push("");
167
+ content.push(centerLine(theme.bold(sessionName.replace(/\s+/g, " ").trim()), screenWidth).trimEnd());
101
168
  content.push("");
102
169
  }
103
170
  const bar = "█".repeat(BAR_WIDTH);
104
171
  const glyphRow = `${bar}${" ".repeat(BAR_GAP)}${bar}`;
105
172
  for (let i = 0; i < BAR_ROWS; i++) {
106
- content.push(centerLine(theme.fg("accent", glyphRow), width).trimEnd());
173
+ content.push(centerLine(theme.fg("accent", glyphRow), screenWidth).trimEnd());
107
174
  }
108
175
  content.push("");
109
- content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), width).trimEnd());
176
+ content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), screenWidth).trimEnd());
110
177
  content.push("");
111
178
  for (const line of BODY_LINES) {
112
- content.push(centerLine(theme.fg("muted", line), width).trimEnd());
179
+ content.push(centerLine(theme.fg("muted", line), screenWidth).trimEnd());
113
180
  }
114
- content.push("");
115
- content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width).trimEnd());
181
+ content.push(centerLine(theme.fg(outstanding ? "warning" : "success", status), screenWidth).trimEnd());
182
+ if (outstanding) {
183
+ content.push(centerLine(theme.fg("muted", workCounts[0]!), screenWidth).trimEnd());
184
+ content.push(centerLine(theme.fg("muted", workCounts[1]!), screenWidth).trimEnd());
185
+ }
186
+ content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), screenWidth).trimEnd());
116
187
  content.push("");
117
188
  const resumeHint = `${esc} · ${formatKeyHint("enter")} · ${formatKeyHint("space")} — resume`;
118
- content.push(centerLine(theme.fg("dim", resumeHint), width).trimEnd());
189
+ content.push(centerLine(theme.fg("dim", resumeHint), screenWidth).trimEnd());
119
190
  }
120
191
 
121
- const topPad = Math.max(0, Math.floor((height - content.length) / 2));
192
+ const topPad = Math.max(0, Math.floor((screenHeight - content.length) / 2));
122
193
  // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
123
194
  const lines: string[] = new Array(topPad).fill("");
124
195
  lines.push(...content);
125
- while (lines.length < height) lines.push("");
126
- return lines.slice(0, Math.max(1, height));
196
+ while (lines.length < screenHeight) lines.push("");
197
+ return lines.slice(0, screenHeight);
127
198
  }
128
199
 
129
200
  /** Fullscreen overlay component; resolves {@link run} when a resume key lands. */
@@ -133,19 +204,25 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
133
204
  #disposed = false;
134
205
  #startedAt = Date.now();
135
206
  #native: NativeNode | undefined;
207
+ #workSnapshot: PauseWorkStatus;
136
208
 
137
- constructor(readonly host: PauseScreenHost) {}
209
+ constructor(
210
+ readonly host: PauseScreenHost,
211
+ readonly options: PauseScreenOptions = {},
212
+ ) {
213
+ this.#workSnapshot = this.#readWorkSnapshot();
214
+ }
138
215
 
139
216
  /** Start the clock; resolves once the user asks to resume. */
140
217
  run(): Promise<void> {
141
218
  this.#startedAt = agentPauseGate.pausedAt ?? Date.now();
142
219
  this.#native = undefined;
143
- // The tick only repaints the hold clock; natively the `elapsed` node counts on its own.
144
- if (!isNativeRendering()) {
145
- this.#timer ??= setInterval(() => {
146
- if (!this.#disposed) this.host.ui.requestRender();
147
- }, TICK_MS);
148
- }
220
+ this.#refreshWorkSnapshot();
221
+ this.#timer ??= setInterval(() => {
222
+ if (this.#disposed) return;
223
+ const statusChanged = this.#refreshWorkSnapshot();
224
+ if (statusChanged || !isNativeRendering()) this.host.ui.requestRender();
225
+ }, TICK_MS);
149
226
  this.host.ui.requestRender();
150
227
  return this.#done.promise;
151
228
  }
@@ -181,6 +258,25 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
181
258
  this.#native = undefined;
182
259
  }
183
260
 
261
+ #readWorkSnapshot(): PauseWorkStatus {
262
+ const workStatus = this.options.getWorkStatus?.() ?? EMPTY_WORK_STATUS;
263
+ return {
264
+ unsettledTurns: workStatus.unsettledTurns,
265
+ unfinishedJobs: workStatus.unfinishedJobs,
266
+ };
267
+ }
268
+
269
+ #refreshWorkSnapshot(): boolean {
270
+ const next = this.#readWorkSnapshot();
271
+ const previous = this.#workSnapshot;
272
+ if (next.unsettledTurns === previous.unsettledTurns && next.unfinishedJobs === previous.unfinishedJobs) {
273
+ return false;
274
+ }
275
+ this.#workSnapshot = next;
276
+ this.#native = undefined;
277
+ return true;
278
+ }
279
+
184
280
  /** A glass sheet titled "Paused" (not the whole pane: the transcript stays visible, frozen). */
185
281
  readonly nativeOverlay = { role: "omp.overlay.pause", size: "md", anchor: "center", head: "Paused" } as const;
186
282
 
@@ -191,6 +287,41 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
191
287
  */
192
288
  describe(): NativeNode {
193
289
  if (this.#native) return this.#native;
290
+ const snapshot = this.#workSnapshot;
291
+ const outstanding = snapshot.unsettledTurns > 0 || snapshot.unfinishedJobs > 0;
292
+ const status = col(
293
+ [
294
+ keyed(
295
+ text(
296
+ [
297
+ span(
298
+ outstanding ? "Pausing — waiting for work to settle" : "Paused — ready to suspend",
299
+ outstanding ? "warning" : "success",
300
+ ),
301
+ ],
302
+ { role: "omp.pause.settlement" },
303
+ ),
304
+ "settlement",
305
+ ),
306
+ ...(outstanding
307
+ ? [
308
+ keyed(
309
+ text([span(`Unsettled turns: ${snapshot.unsettledTurns}`, "muted")], {
310
+ role: "omp.pause.unsettled-turn-count",
311
+ }),
312
+ "unsettled-turns",
313
+ ),
314
+ keyed(
315
+ text([span(`Unfinished jobs: ${snapshot.unfinishedJobs}`, "muted")], {
316
+ role: "omp.pause.unfinished-job-count",
317
+ }),
318
+ "unfinished-jobs",
319
+ ),
320
+ ]
321
+ : []),
322
+ ],
323
+ { role: "omp.pause.settlement-status", gap: "sm" },
324
+ );
194
325
  const resumeKey = boundKeys("app.interrupt", ["escape"])[0] ?? "escape";
195
326
  const hero = row(
196
327
  [
@@ -244,12 +375,9 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
244
375
  this.#native = col(
245
376
  [
246
377
  keyed(hero, "hero"),
378
+ keyed(status, "settlement-status"),
247
379
  keyed(facts, "facts"),
248
380
  actionBar([
249
- keyed(
250
- text([span("Each agent continues where it stopped.", "muted")], { role: "omp.pause.note" }),
251
- "note",
252
- ),
253
381
  null,
254
382
  actionButton("Resume", "resume", {
255
383
  keys: resumeKey,
@@ -276,6 +404,7 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
276
404
  Math.max(1, this.host.ui.terminal.rows),
277
405
  elapsed,
278
406
  this.host.sessionName,
407
+ this.#workSnapshot,
279
408
  );
280
409
  }
281
410
  }
@@ -286,9 +415,9 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
286
415
  * gate on the way out (including teardown throws) — a leaked pause would
287
416
  * freeze every agent in the process with no UI left to release it.
288
417
  */
289
- export async function runPauseScreen(host: PauseScreenHost): Promise<void> {
418
+ export async function runPauseScreen(host: PauseScreenHost, options?: PauseScreenOptions): Promise<void> {
290
419
  if (!agentPauseGate.pause()) return;
291
- const component = new PauseScreenComponent(host);
420
+ const component = new PauseScreenComponent(host, options);
292
421
  const overlay = host.ui.showOverlay(component, {
293
422
  width: "100%",
294
423
  maxHeight: "100%",
@@ -1,4 +1,4 @@
1
- import type { AgentHubRegistry } from "./agent-hub-types";
1
+ import { type AgentHubRegistry, MAIN_AGENT_ID } from "./agent-hub-types";
2
2
 
3
3
  export interface RunningSubagentRegistrySource<T extends AgentHubRegistry = AgentHubRegistry> {
4
4
  agentRegistry: T;
@@ -17,3 +17,14 @@ export function getRunningSubagentBadgeAgentIds(registry: AgentHubRegistry): str
17
17
  .filter(ref => ref.kind === "sub" && ref.status === "running")
18
18
  .map(ref => ref.id);
19
19
  }
20
+
21
+ /**
22
+ * Agents the Agent Hub roster can show (every registry ref but the main
23
+ * session), whatever their status: finished, parked and killed subagents
24
+ * stay inspectable after the last one stops running.
25
+ */
26
+ export function getInspectableSubagentCount(registry: AgentHubRegistry): number {
27
+ let count = 0;
28
+ for (const ref of registry.list()) if (ref.id !== MAIN_AGENT_ID) count++;
29
+ return count;
30
+ }
@@ -296,8 +296,8 @@ export class OutputPane implements Component {
296
296
  * *without* the pane hearing about it, and every one of them shapes the
297
297
  * content:
298
298
  *
299
- * - `width` — the wrapping width, which feeds the visual cap as well as
300
- * the inner `Text` render.
299
+ * - `contentWidth` — the wrapping width that feeds the visual cap as well
300
+ * as the inner `Text` render: `width` minus both horizontal paddings.
301
301
  * - `widthConfigEpoch` — the width-affecting runtime configuration (the
302
302
  * Hangul compatibility-Jamo width), which `visibleWidth` — and so the
303
303
  * visual cap — depends on.
@@ -317,7 +317,7 @@ export class OutputPane implements Component {
317
317
  render(width: number): readonly string[] {
318
318
  const paddingX = getPaddingX(this.#options.paddingX ?? 0);
319
319
  const contentWidth = Math.max(1, width - paddingX * 2);
320
- const content = this.#memo.get([...this.#renderDeps(), width], () => {
320
+ const content = this.#memo.get([...this.#renderDeps(), contentWidth], () => {
321
321
  const formatted = formatOutputPaneLines(
322
322
  {
323
323
  ...this.#options,
@@ -990,24 +990,26 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
990
990
  this.#invalidateStatusLineRenderCache();
991
991
  }
992
992
 
993
- setRunningSubagents(agentIds: readonly string[]): void {
993
+ setRunningSubagents(agentIds: readonly string[]): boolean {
994
994
  if (
995
995
  agentIds.length === this.#runningSubagentIds.size &&
996
996
  agentIds.every(agentId => this.#runningSubagentIds.has(agentId))
997
997
  ) {
998
- return;
998
+ return false;
999
999
  }
1000
1000
  this.#subagentCount = agentIds.length;
1001
1001
  this.#runningSubagentIds = new Set(agentIds);
1002
1002
  this.#invalidateStatusLineRenderCache();
1003
+ return true;
1003
1004
  }
1004
1005
 
1005
1006
  /** Host-computed spend of the main session's whole subagent tree (Agent Hub projection). */
1006
- setSubagentTreeCost(cost: number): void {
1007
+ setSubagentTreeCost(cost: number): boolean {
1007
1008
  const next = Number.isFinite(cost) && cost > 0 ? cost : 0;
1008
- if (next === this.#subagentTreeCost) return;
1009
+ if (next === this.#subagentTreeCost) return false;
1009
1010
  this.#subagentTreeCost = next;
1010
1011
  this.#invalidateStatusLineRenderCache();
1012
+ return true;
1011
1013
  }
1012
1014
 
1013
1015
  /**