@aragon-agent/cli 0.5.1 → 0.5.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +155 -5
  3. package/dist/agent/controller.d.ts +51 -1
  4. package/dist/agent/controller.js +177 -2
  5. package/dist/agent/headless.d.ts +12 -0
  6. package/dist/agent/headless.js +41 -1
  7. package/dist/agent/reducer.d.ts +48 -0
  8. package/dist/agent/reducer.js +91 -2
  9. package/dist/agent/system-prompt.d.ts +17 -0
  10. package/dist/agent/system-prompt.js +4 -0
  11. package/dist/cli.js +131 -6
  12. package/dist/commands/builtins.js +82 -0
  13. package/dist/config/app-paths.d.ts +17 -0
  14. package/dist/config/app-paths.js +21 -0
  15. package/dist/config/cli-commands.d.ts +26 -2
  16. package/dist/config/cli-commands.js +63 -4
  17. package/dist/config/env.d.ts +2 -2
  18. package/dist/config/env.js +36 -2
  19. package/dist/config/history-commands.d.ts +20 -0
  20. package/dist/config/history-commands.js +71 -0
  21. package/dist/config/load.d.ts +15 -0
  22. package/dist/config/load.js +88 -5
  23. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  24. package/dist/config/migrate-state-out-of-config.js +85 -0
  25. package/dist/config/prompt-history.d.ts +86 -0
  26. package/dist/config/prompt-history.js +332 -0
  27. package/dist/config/schema.d.ts +125 -9
  28. package/dist/config/schema.js +90 -5
  29. package/dist/config/store.js +13 -4
  30. package/dist/config/ui-state.d.ts +52 -0
  31. package/dist/config/ui-state.js +149 -0
  32. package/dist/input/mouse-events.d.ts +61 -0
  33. package/dist/input/mouse-events.js +153 -0
  34. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  35. package/dist/input/stdin-mouse-filter.js +153 -0
  36. package/dist/logging/install.d.ts +85 -0
  37. package/dist/logging/install.js +94 -0
  38. package/dist/logging/logger.d.ts +1 -1
  39. package/dist/session/persist.d.ts +16 -0
  40. package/dist/session/persist.js +19 -1
  41. package/dist/team/activity.d.ts +67 -0
  42. package/dist/team/activity.js +130 -0
  43. package/dist/team/bus.d.ts +190 -0
  44. package/dist/team/bus.js +366 -0
  45. package/dist/team/comm-tools.d.ts +61 -0
  46. package/dist/team/comm-tools.js +221 -0
  47. package/dist/team/human-queue.d.ts +55 -0
  48. package/dist/team/human-queue.js +72 -0
  49. package/dist/team/limits.d.ts +123 -0
  50. package/dist/team/limits.js +128 -0
  51. package/dist/team/normalize.d.ts +39 -0
  52. package/dist/team/normalize.js +106 -0
  53. package/dist/team/panel-rows.d.ts +48 -0
  54. package/dist/team/panel-rows.js +75 -0
  55. package/dist/team/prompt.d.ts +49 -0
  56. package/dist/team/prompt.js +87 -0
  57. package/dist/team/report.d.ts +58 -0
  58. package/dist/team/report.js +205 -0
  59. package/dist/team/retry.d.ts +48 -0
  60. package/dist/team/retry.js +56 -0
  61. package/dist/team/runtime.d.ts +122 -0
  62. package/dist/team/runtime.js +406 -0
  63. package/dist/team/subagent.d.ts +90 -0
  64. package/dist/team/subagent.js +294 -0
  65. package/dist/team/task-tool.d.ts +47 -0
  66. package/dist/team/task-tool.js +118 -0
  67. package/dist/team/types.d.ts +172 -0
  68. package/dist/team/types.js +13 -0
  69. package/dist/tools/index.d.ts +48 -2
  70. package/dist/tools/index.js +26 -1
  71. package/dist/ui/App.d.ts +9 -1
  72. package/dist/ui/App.js +140 -21
  73. package/dist/ui/Composer.d.ts +6 -1
  74. package/dist/ui/Composer.js +2 -2
  75. package/dist/ui/PromptInput.d.ts +15 -1
  76. package/dist/ui/PromptInput.js +38 -1
  77. package/dist/ui/StatusBar.d.ts +21 -0
  78. package/dist/ui/StatusBar.js +5 -2
  79. package/dist/ui/TeamPanel.d.ts +57 -0
  80. package/dist/ui/TeamPanel.js +134 -0
  81. package/dist/ui/Transcript.js +10 -0
  82. package/dist/ui/entries/TeamCard.d.ts +31 -0
  83. package/dist/ui/entries/TeamCard.js +39 -0
  84. package/dist/ui/glyphs.d.ts +22 -0
  85. package/dist/ui/glyphs.js +10 -0
  86. package/dist/ui/layout/AppShell.d.ts +20 -1
  87. package/dist/ui/layout/AppShell.js +39 -4
  88. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  89. package/dist/ui/layout/ScrollIndicator.js +15 -0
  90. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  91. package/dist/ui/layout/ScrollViewport.js +24 -4
  92. package/dist/ui/layout/regions.d.ts +55 -0
  93. package/dist/ui/layout/regions.js +52 -0
  94. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  95. package/dist/ui/layout/scroll-indicator.js +35 -0
  96. package/dist/ui/layout/scroll.d.ts +9 -0
  97. package/dist/ui/layout/scroll.js +23 -0
  98. package/dist/ui/overlays/HelpOverlay.js +14 -0
  99. package/dist/ui/screen.d.ts +14 -1
  100. package/dist/ui/screen.js +37 -3
  101. package/dist/ui/transcript-text.js +20 -0
  102. package/dist/ui/use-startup-notices.d.ts +11 -1
  103. package/dist/ui/use-startup-notices.js +29 -2
  104. package/dist/ui/use-wheel-routing.d.ts +68 -0
  105. package/dist/ui/use-wheel-routing.js +139 -0
  106. package/package.json +2 -2
@@ -0,0 +1,57 @@
1
+ /**
2
+ * TeamPanel — the live subagent roster (team-subagents §6.1).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
5
+ * capability probe and shows mojibake on a legacy console (`glyphs.ts` header).
6
+ *
7
+ * RENDERED ONLY WHILE A DISPATCH IS RUNNING (D-11). The requirement says "(dang
8
+ * you de shi hou)" — when there is one — and a permanent zero-row region would
9
+ * be furniture. `App` passes `null` the rest of the time and this component is
10
+ * not mounted at all, so a session that never delegates has zero rows and zero
11
+ * layout shift.
12
+ *
13
+ * IT MUST BE MOUNTED INSIDE `AppShell`'s MEASURED BOTTOM BOX (I-10). The wheel
14
+ * router reads the composer band boundary from `measureElement(bottomRef)`; a
15
+ * panel rendered outside it makes that boundary stale the moment a dispatch
16
+ * starts, and the wheel begins scrolling prompt history over the transcript.
17
+ */
18
+ import React from 'react';
19
+ import type { Theme } from './theme.js';
20
+ import type { TermCapabilities } from './capabilities.js';
21
+ import type { SubagentRun, TeamSnapshot } from '../team/types.js';
22
+ export interface TeamPanelProps {
23
+ snapshot: TeamSnapshot;
24
+ /** Terminal rows, for the one-line collapse below `panelCollapseRows`. */
25
+ rows: number;
26
+ /**
27
+ * Terminal columns. REQUIRED, not optional (D-13): an optional width
28
+ * defaulting to narrow would silently produce the degraded form forever if a
29
+ * call site forgot it, where a required prop fails at compile time.
30
+ */
31
+ cols: number;
32
+ reducedMotion: boolean;
33
+ theme: Theme;
34
+ caps: TermCapabilities;
35
+ /** Injectable clock so the panel test does not depend on wall time. */
36
+ now?: number;
37
+ }
38
+ export declare function activityBudget(cols: number): number;
39
+ /**
40
+ * The middle column: what this child is doing right now, in a few words.
41
+ *
42
+ * Two width policies, answering different questions. `wide` decides WHICH FACT
43
+ * to show - a whole path or its basename, a whole command line or its program
44
+ * name - mirroring `StatusBar`'s `statusCompactCols` degradation rather than
45
+ * inventing a second threshold. `activityBudget(cols)` decides HOW MUCH OF IT
46
+ * FITS, and it is a continuous function of width rather than a threshold,
47
+ * because the failure it prevents - a row wider than the terminal - is
48
+ * continuous.
49
+ *
50
+ * Exported for the same reason `buildSubagentTools` is: the width bound is the
51
+ * property this round has to keep, and asserting it through a rendered frame
52
+ * cannot distinguish "the arithmetic clamped it" from "Ink truncated it" - which
53
+ * is precisely the confusion that let v1's fixed-width column look fine at 100
54
+ * columns and break at 80.
55
+ */
56
+ export declare function activityLine(run: SubagentRun, cols: number): string;
57
+ export declare function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, }: TeamPanelProps): React.ReactElement;
@@ -0,0 +1,134 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { pickGlyphs } from './glyphs.js';
5
+ import { formatDuration } from '../agent/usage.js';
6
+ import { describeToolActivity, sanitizeActivity } from '../team/activity.js';
7
+ import { TEAM_LIMITS } from '../team/limits.js';
8
+ import { selectPanelRows } from '../team/panel-rows.js';
9
+ function isRunning(run) {
10
+ return run.phase === 'starting' || run.phase === 'thinking' || run.phase === 'tool' ||
11
+ run.phase === 'waiting';
12
+ }
13
+ function isSettled(run) {
14
+ return run.phase === 'done' || run.phase === 'failed' || run.phase === 'aborted';
15
+ }
16
+ function statusColor(run, theme) {
17
+ if (run.phase === 'failed' || run.phase === 'aborted')
18
+ return theme.toolError;
19
+ if (run.phase === 'done')
20
+ return theme.toolDone;
21
+ if (isRunning(run))
22
+ return theme.toolRunning;
23
+ return theme.muted;
24
+ }
25
+ /**
26
+ * How many characters the activity column may spend at this width (P0-1).
27
+ *
28
+ * `TEAM_LIMITS.activityChars` is a STORAGE ceiling; a terminal has a COLUMN
29
+ * budget, and the two are not the same number. The row is
30
+ *
31
+ * marker(3) + label(8) + description(flex) + 2 + activity + 2 + elapsed(~6)
32
+ *
33
+ * and the comment on the description cell below promises that activity and
34
+ * elapsed never lose characters, i.e. that `description` absorbs ALL width
35
+ * pressure. A 64-character prose tail plus `writing: ` is 73, which on an
36
+ * 80-column terminal needs 94 columns before `description` gets one. The promise
37
+ * cannot be kept by wishing; the column has to be sized from `cols`.
38
+ *
39
+ * `WIDE_CHAR_ALLOWANCE` is the concession to there being no display-width helper
40
+ * in production code: this budget is denominated in code units while the
41
+ * terminal is denominated in columns, and CJK and emoji occupy two columns per
42
+ * unit, so the budget is halved against the reserve rather than spent to the
43
+ * last cell. Erring small costs a few characters of prose; erring large costs
44
+ * the elapsed column.
45
+ */
46
+ const ROW_RESERVED_COLS = 3 + 8 + 2 + 2 + 8 + 12; // marker, label, gaps, elapsed, min description
47
+ const WIDE_CHAR_ALLOWANCE = 2;
48
+ export function activityBudget(cols) {
49
+ const spare = Math.floor((cols - ROW_RESERVED_COLS) / WIDE_CHAR_ALLOWANCE);
50
+ return Math.max(TEAM_LIMITS.activityMinChars, Math.min(TEAM_LIMITS.activityChars, spare));
51
+ }
52
+ /**
53
+ * The middle column: what this child is doing right now, in a few words.
54
+ *
55
+ * Two width policies, answering different questions. `wide` decides WHICH FACT
56
+ * to show - a whole path or its basename, a whole command line or its program
57
+ * name - mirroring `StatusBar`'s `statusCompactCols` degradation rather than
58
+ * inventing a second threshold. `activityBudget(cols)` decides HOW MUCH OF IT
59
+ * FITS, and it is a continuous function of width rather than a threshold,
60
+ * because the failure it prevents - a row wider than the terminal - is
61
+ * continuous.
62
+ *
63
+ * Exported for the same reason `buildSubagentTools` is: the width bound is the
64
+ * property this round has to keep, and asserting it through a rendered frame
65
+ * cannot distinguish "the arithmetic clamped it" from "Ink truncated it" - which
66
+ * is precisely the confusion that let v1's fixed-width column look fine at 100
67
+ * columns and break at 80.
68
+ */
69
+ export function activityLine(run, cols) {
70
+ const wide = cols >= TEAM_LIMITS.activityWideCols;
71
+ const budget = activityBudget(cols);
72
+ switch (run.phase) {
73
+ case 'queued':
74
+ return 'queued';
75
+ case 'starting':
76
+ // The one row that legitimately restarts its clock, saying why (P2-4):
77
+ // F-2 ranks the running group by `startedAt` and a retry re-stamps it.
78
+ return run.retries ? 'starting (retry)' : 'starting';
79
+ case 'thinking':
80
+ // Clamped AT RENDER, against this terminal's budget. `run.activity` was
81
+ // clamped at STORE against `activityChars`; that is a ceiling, not a fit.
82
+ return run.activity ? `writing: ${sanitizeActivity(run.activity, budget)}` : 'thinking';
83
+ case 'tool':
84
+ case 'waiting':
85
+ return run.lastTool
86
+ ? sanitizeActivity(describeToolActivity(run.lastTool, run.activityArgs, wide), budget)
87
+ : run.phase === 'waiting'
88
+ ? 'waiting for mail'
89
+ : 'tool';
90
+ case 'done':
91
+ return `done ${run.turns} turns`;
92
+ case 'failed':
93
+ return 'failed';
94
+ case 'aborted':
95
+ return 'aborted';
96
+ }
97
+ }
98
+ export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, }) {
99
+ const glyphs = pickGlyphs(caps);
100
+ const at = now ?? Date.now();
101
+ const running = snapshot.runs.filter(isRunning).length;
102
+ const done = snapshot.runs.filter(isSettled).length;
103
+ const elapsed = formatDuration(Math.max(0, at - snapshot.startedAt));
104
+ const header = (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", color: theme.accent, bold: true, children: [glyphs.teamAgent, " team", ' ', _jsxs(Text, { color: theme.muted, children: [running, " running ", glyphs.midDot, " ", done, " done ", glyphs.midDot, " ", elapsed] })] }), snapshot.messageCount > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.teamMail, " ", snapshot.messageCount] }))] }));
105
+ // Below 20 rows the roster is more expensive than the transcript it displaces,
106
+ // so the panel degrades to the one line that still answers "is something
107
+ // running, and for how long" (§6.4).
108
+ if (rows < TEAM_LIMITS.panelCollapseRows) {
109
+ return _jsx(Box, { flexDirection: "column", flexShrink: 0, children: header });
110
+ }
111
+ // RANKED, not sliced by array index (F-2). The slot pool hands out indices in
112
+ // order, so `slice(0, 5)` showed the five children that finished FIRST and hid
113
+ // everything still working behind `+N more` - at exactly the fan-out widths
114
+ // the requirement permits.
115
+ const { visible, hiddenTotal, hiddenRunning } = selectPanelRows(snapshot.runs, TEAM_LIMITS.panelMaxRows);
116
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [header, visible.map((run) => {
117
+ const color = statusColor(run, theme);
118
+ // One spinner per RUNNING row, and the row cap is 5 precisely because
119
+ // five is the documented maximum number of simultaneous spinners. Both
120
+ // `reducedMotion` and a non-Unicode terminal fall back to the same
121
+ // static marker, exactly as `AssistantEntry` and `ToolCard` already do.
122
+ const animate = isRunning(run) && !reducedMotion && caps.unicode;
123
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: color, children: [' ', animate ? _jsx(Spinner, { type: "dots" }) : marker(run, glyphs), ' '] }), _jsx(Text, { color: theme.primary, children: run.label.padEnd(8).slice(0, 8) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: theme.muted, children: run.description }) }), _jsx(Box, { flexShrink: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: color, children: [' ', activityLine(run, cols)] }) }), _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? at) - run.startedAt)) : ''] })] }, run.label));
124
+ }), hiddenTotal > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenTotal, " more", hiddenRunning > 0 ? ` (${hiddenRunning} running)` : ''] })), snapshot.lastMessage && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.teamMail, " ", snapshot.lastMessage.from, " ", glyphs.arrowRight, " ", snapshot.lastMessage.to, ": ", snapshot.lastMessage.subject] }))] }));
125
+ }
126
+ function marker(run, glyphs) {
127
+ if (run.phase === 'done')
128
+ return glyphs.toolDone;
129
+ if (run.phase === 'failed' || run.phase === 'aborted')
130
+ return glyphs.toolError;
131
+ if (run.phase === 'queued')
132
+ return glyphs.toolPending;
133
+ return glyphs.spinnerStill;
134
+ }
@@ -26,6 +26,7 @@ import { EntryFrame } from './entries/EntryFrame.js';
26
26
  import { UserEntry } from './entries/UserEntry.js';
27
27
  import { AssistantEntry } from './entries/AssistantEntry.js';
28
28
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
29
+ import { TeamCard, teamCardColor } from './entries/TeamCard.js';
29
30
  /** Keep the last K entries in the live region so a mutating tail stays hot. */
30
31
  const LIVE_TAIL = 1;
31
32
  /**
@@ -47,6 +48,13 @@ export function computeSettledCount(entries, expandedToolIds) {
47
48
  break;
48
49
  if (e.kind === 'tool' && e.status !== 'done' && e.status !== 'error')
49
50
  break;
51
+ // A live dispatch is not settled: its `runs` are rewritten on every
52
+ // `teamUpdate`, and `<Static>` cannot un-print what it has already drawn.
53
+ // The boundary is held MONOTONIC above, which is also why a `team` entry
54
+ // that never settles would be re-rendered on every frame for the rest of the
55
+ // session — the failure `session/persist.ts` normalizes away on load (P1-5).
56
+ if (e.kind === 'team' && e.active)
57
+ break;
50
58
  count = i + 1;
51
59
  }
52
60
  return count;
@@ -81,6 +89,8 @@ function EntryView({ entry, prev, expanded, thinkingVisible, reducedMotion, dens
81
89
  }
82
90
  case 'tool':
83
91
  return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
92
+ case 'team':
93
+ return frame(glyphs.teamAgent, teamCardColor(entry.runs, entry.active, entry.aborted, theme), _jsx(TeamCard, { requested: entry.requested, runs: entry.runs, aborted: entry.aborted, durationMs: entry.durationMs, active: entry.active, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
84
94
  case 'notice':
85
95
  return frame(noticeSymbol(entry.level, theme), noticeColor(entry.level, theme), _jsx(Text, { color: noticeColor(entry.level, theme), children: entry.text }));
86
96
  default:
@@ -0,0 +1,31 @@
1
+ /**
2
+ * TeamCard — the settled-dispatch transcript entry (team-subagents §6.3).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`.
5
+ *
6
+ * The panel is EPHEMERAL and this card is the HISTORY. It renders like a
7
+ * `ToolCard` — same `EntryFrame` supplied by `EntryView`, same left rail for the
8
+ * expanded body — so a dispatch reads as one more step in the transcript rather
9
+ * than a second kind of object.
10
+ *
11
+ * `Ctrl+O` expands it to the per-agent summaries, which is why `App.tsx`'s
12
+ * Ctrl+O finder had to widen from `kind === 'tool'` to `tool || team`.
13
+ */
14
+ import React from 'react';
15
+ import type { Theme } from '../theme.js';
16
+ import type { TermCapabilities } from '../capabilities.js';
17
+ import type { SubagentRun } from '../../team/types.js';
18
+ export interface TeamCardProps {
19
+ requested: number;
20
+ runs: SubagentRun[];
21
+ aborted: boolean;
22
+ durationMs?: number;
23
+ active: boolean;
24
+ expanded?: boolean;
25
+ reducedMotion?: boolean;
26
+ theme: Theme;
27
+ caps: TermCapabilities;
28
+ }
29
+ /** The card's aggregate colour: any failure dominates. */
30
+ export declare function teamCardColor(runs: SubagentRun[], active: boolean, aborted: boolean, theme: Theme): string | undefined;
31
+ export declare function TeamCard(props: TeamCardProps): React.ReactElement;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { pickGlyphs } from '../glyphs.js';
5
+ import { railBorderProps } from '../layout/Gutter.js';
6
+ import { formatDuration } from '../../agent/usage.js';
7
+ function isOk(run) {
8
+ return run.phase === 'done' && !run.error;
9
+ }
10
+ /** The card's aggregate colour: any failure dominates. */
11
+ export function teamCardColor(runs, active, aborted, theme) {
12
+ if (active)
13
+ return theme.toolRunning;
14
+ if (aborted || runs.some((r) => !isOk(r)))
15
+ return theme.toolError;
16
+ return theme.toolDone;
17
+ }
18
+ export function TeamCard(props) {
19
+ const { requested, runs, aborted, durationMs, active, expanded, reducedMotion, theme, caps } = props;
20
+ const glyphs = pickGlyphs(caps);
21
+ const color = teamCardColor(runs, active, aborted, theme);
22
+ const ok = runs.filter(isOk).length;
23
+ const failed = runs.length - ok;
24
+ // A dispatch entry restored from `/resume` is settled and aborted but has no
25
+ // duration: nothing measured it, because the process it belonged to is gone.
26
+ // Saying so plainly beats rendering `0.0s (0 ok, 3 failed)`, which reads like
27
+ // a dispatch that ran and lost (§5.3 / P1-5).
28
+ const interrupted = !active && aborted && durationMs === undefined;
29
+ const settled = runs.filter((r) => r.phase === 'done' || r.phase === 'failed' || r.phase === 'aborted').length;
30
+ const headline = interrupted
31
+ ? 'interrupted (session resumed)'
32
+ : active
33
+ ? `${runs.length - settled} running`
34
+ : `${formatDuration(durationMs ?? 0)} (${ok} ok, ${failed} failed)`;
35
+ const count = `${runs.length} ${runs.length === 1 ? 'subagent' : 'subagents'}`;
36
+ const dropped = requested > runs.length ? ` (${runs.length} of ${requested} requested)` : '';
37
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "team" }), _jsxs(Text, { color: theme.muted, children: [' ', count, dropped, ' '] }), active && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " ", headline] })) : (_jsxs(Text, { color: color, children: [active ? `${glyphs.spinnerStill} ` : '', headline] }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [runs.map((run) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: isOk(run) ? theme.toolDone : theme.toolError, children: isOk(run) ? glyphs.toolDone : glyphs.toolError }), _jsxs(Text, { color: theme.primary, children: [" ", run.label.padEnd(8).slice(0, 8)] }), _jsxs(Text, { color: theme.muted, children: [' ', run.description, ' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? 0) - run.startedAt)) : '', ` ${run.turns} turns`, run.filesTouched.length > 0 ? ` ${run.filesTouched.length} files` : '', run.error ? ` ${run.error}` : ''] })] }, run.label))), expanded &&
38
+ runs.map((run) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.accent, children: [run.label, " ", glyphs.midDot, " ", run.description] }), _jsx(Text, { color: theme.muted, children: run.summary ?? '(no summary)' })] }, `s-${run.label}`))), !expanded && !active && runs.length > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", runs.length, " ", runs.length === 1 ? 'summary' : 'summaries', " (Ctrl+O)"] })), expanded && _jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" })] })] }));
39
+ }
@@ -72,8 +72,30 @@ export interface Glyphs {
72
72
  railVertical: string;
73
73
  railBranch: string;
74
74
  railEnd: string;
75
+ /**
76
+ * Scroll-position indicator on the viewport's right edge (mouse-wheel §4.8).
77
+ *
78
+ * These are byte-for-byte identical to `railVertical` / `gaugeFull` in both
79
+ * tiers, and that is DELIBERATE (P2-2). This file's header mandates "add a
80
+ * field here instead" of a new exception, and semantic field names are what
81
+ * make the ASCII tier reviewable at a glance. Reusing `gaugeFull` would
82
+ * couple the scroll thumb to the context-window gauge, so that restyling one
83
+ * silently restyles the other — do not "deduplicate" them.
84
+ */
85
+ scrollTrack: string;
86
+ scrollThumb: string;
75
87
  /** Redaction dot for `maskSecret` in the settings screen (§4.1 tier B). */
76
88
  maskDot: string;
89
+ /**
90
+ * Team mode (team-subagents §6.1). The rail marker for a dispatch card and
91
+ * the prefix on the panel's incoming-message preview.
92
+ *
93
+ * They live HERE rather than in `TeamPanel` / `TeamCard` for the reason this
94
+ * file's header gives: a literal in a component bypasses `pickGlyphs` entirely
95
+ * and shows mojibake on a legacy console, whatever the capability probe said.
96
+ */
97
+ teamAgent: string;
98
+ teamMail: string;
77
99
  /**
78
100
  * Ink `borderStyle` name for every boxed region (composer, completion popup,
79
101
  * overlay frame).
package/dist/ui/glyphs.js CHANGED
@@ -55,8 +55,12 @@ const UNICODE_GLYPHS = {
55
55
  railVertical: '│',
56
56
  railBranch: '├',
57
57
  railEnd: '╰',
58
+ scrollTrack: '│',
59
+ scrollThumb: '█',
58
60
  maskDot: '•',
59
61
  boxStyle: 'round',
62
+ teamAgent: '◆',
63
+ teamMail: '✉',
60
64
  tool: {
61
65
  read_file: '▤',
62
66
  write_file: '✎',
@@ -67,6 +71,7 @@ const UNICODE_GLYPHS = {
67
71
  list_dir: '▤',
68
72
  ask_user: '?',
69
73
  submit_plan: '◈',
74
+ task: '◆',
70
75
  },
71
76
  toolDefault: '•',
72
77
  };
@@ -107,8 +112,12 @@ const ASCII_GLYPHS = {
107
112
  railVertical: '|',
108
113
  railBranch: '|',
109
114
  railEnd: '\\',
115
+ scrollTrack: '|',
116
+ scrollThumb: '#',
110
117
  maskDot: '*',
111
118
  boxStyle: 'classic',
119
+ teamAgent: '*',
120
+ teamMail: '@',
112
121
  tool: {
113
122
  read_file: '=',
114
123
  write_file: '+',
@@ -119,6 +128,7 @@ const ASCII_GLYPHS = {
119
128
  list_dir: '=',
120
129
  ask_user: '?',
121
130
  submit_plan: '#',
131
+ task: '*',
122
132
  },
123
133
  toolDefault: '*',
124
134
  };
@@ -11,14 +11,33 @@
11
11
  */
12
12
  import React from 'react';
13
13
  import { type RenderMode } from './frame.js';
14
+ import { type FrameGeometry } from './regions.js';
14
15
  export interface AppShellProps {
15
16
  mode: RenderMode;
16
17
  rows: number;
17
18
  cols: number;
18
19
  header: React.ReactNode;
19
20
  viewport: React.ReactNode;
21
+ /**
22
+ * The live subagent roster (team-subagents §6.1), or nothing.
23
+ *
24
+ * IT RENDERS INSIDE `bottomRef` IN THE FULL-SCREEN BRANCH, and it has to
25
+ * (I-10): `geometryRef.current.bottom` is MEASURED from that box, and the
26
+ * wheel router uses it to decide whether a notch belongs to the transcript or
27
+ * to prompt history. A panel rendered above the measured box would make the
28
+ * composer band boundary stale the instant a dispatch started.
29
+ */
30
+ team?: React.ReactNode;
20
31
  toast: React.ReactNode;
21
32
  composer: React.ReactNode;
22
33
  status: React.ReactNode;
34
+ /**
35
+ * Where the wheel router reads the composer band boundary from (mouse-wheel
36
+ * §4.5). Written in a LAYOUT EFFECT to a MUTABLE REF, never to state: the
37
+ * measurement happens on every commit, and routing it through `setState`
38
+ * would add a re-render to the streaming path for a value nothing renders
39
+ * (R-7).
40
+ */
41
+ geometryRef?: React.MutableRefObject<FrameGeometry>;
23
42
  }
24
- export declare function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }: AppShellProps): React.ReactElement;
43
+ export declare function AppShell({ mode, rows, cols, header, viewport, team, toast, composer, status, geometryRef, }: AppShellProps): React.ReactElement;
@@ -1,9 +1,44 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box } from 'ink';
2
+ /**
3
+ * AppShell (spec §4.3) — the frame that makes R2 true.
4
+ *
5
+ * In `fullscreen` the root box has a FIXED height of `frameHeight(rows)` and the
6
+ * middle viewport is the only `flexGrow` child, so the bottom chrome (toast /
7
+ * composer / status bar) is physically the last thing on the screen from the
8
+ * very first frame — empty session, long session, mid-scroll, overlay open.
9
+ *
10
+ * In `inline` the same children are emitted in the same order with no height
11
+ * constraint, i.e. byte-for-byte the v0.2.0 document-flow behavior.
12
+ */
13
+ import { useLayoutEffect, useRef } from 'react';
14
+ import { Box, measureElement } from 'ink';
3
15
  import { frameHeight } from './frame.js';
4
- export function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }) {
16
+ import { UNMEASURED_GEOMETRY } from './regions.js';
17
+ export function AppShell({ mode, rows, cols, header, viewport, team, toast, composer, status, geometryRef, }) {
18
+ const bottomRef = useRef(null);
19
+ // DECLARED ABOVE THE INLINE EARLY RETURN, and it has to stay there: React
20
+ // keys hooks by call order, and a hook that only runs in one mode changes
21
+ // that order between modes.
22
+ //
23
+ // Ink computes yoga layout in `onRender`, which React runs BEFORE layout
24
+ // effects, so this reflects the commit that just happened — the same
25
+ // mechanism `ScrollViewport` already relies on.
26
+ useLayoutEffect(() => {
27
+ if (!geometryRef)
28
+ return;
29
+ if (mode === 'inline' || !bottomRef.current) {
30
+ geometryRef.current = UNMEASURED_GEOMETRY;
31
+ return;
32
+ }
33
+ geometryRef.current = {
34
+ // 1 in the alternate screen — see the note on `FrameGeometry.top`.
35
+ top: 1,
36
+ height: frameHeight(rows),
37
+ bottom: measureElement(bottomRef.current).height,
38
+ };
39
+ });
5
40
  if (mode === 'inline') {
6
- return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, toast, composer, status] }));
41
+ return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, team, toast, composer, status] }));
7
42
  }
8
- return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [toast, composer, status] })] }));
43
+ return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { ref: bottomRef, flexDirection: "column", flexShrink: 0, children: [team, toast, composer, status] })] }));
9
44
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ScrollIndicator (mouse-wheel-region-routing §4.8) — the one-column scroll
3
+ * position rail on the viewport's right edge.
4
+ *
5
+ * NAMING (P2-1): this is NOT `layout/Gutter.tsx`. That one is the two-column
6
+ * left rail every transcript entry sits in, and it carries an invariant of its
7
+ * own. Two components called a gutter, on opposite edges, for unrelated
8
+ * purposes, is how a codebase stops being able to say what it means.
9
+ *
10
+ * THE COLUMN IS RESERVED UNCONDITIONALLY, track glyph and all. Showing it only
11
+ * while scrolled would change the content width, re-wrap every line, change the
12
+ * content height, and feed that back into the measurement that decides whether
13
+ * to show it — a shimmer at the exact moment the user is reading. Constant
14
+ * width, no feedback loop (D-9); `width={1}` and `flexShrink={0}` hold the
15
+ * column even on the first frame, before anything has been measured.
16
+ */
17
+ import React from 'react';
18
+ import type { Theme } from '../theme.js';
19
+ import type { TermCapabilities } from '../capabilities.js';
20
+ import type { ThumbRange } from './scroll-indicator.js';
21
+ /** Below this width a column of content is worth more than the affordance. */
22
+ export declare const MIN_INDICATOR_COLS = 50;
23
+ export interface ScrollIndicatorProps {
24
+ /** Viewport height in rows, as measured by `ScrollViewport`. */
25
+ rows: number;
26
+ /** `null` when the content fits — the rail is drawn as bare track. */
27
+ thumb: ThumbRange | null;
28
+ theme: Theme;
29
+ caps: TermCapabilities;
30
+ }
31
+ export declare function ScrollIndicator({ rows, thumb, theme, caps, }: ScrollIndicatorProps): React.ReactElement;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from '../glyphs.js';
4
+ /** Below this width a column of content is worth more than the affordance. */
5
+ export const MIN_INDICATOR_COLS = 50;
6
+ export function ScrollIndicator({ rows, thumb, theme, caps, }) {
7
+ const glyphs = pickGlyphs(caps);
8
+ const count = Math.max(0, Math.floor(rows) || 0);
9
+ const cells = [];
10
+ for (let i = 0; i < count; i += 1) {
11
+ const onThumb = !!thumb && i >= thumb.start && i < thumb.start + thumb.size;
12
+ cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.border, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
13
+ }
14
+ return (_jsx(Box, { flexDirection: "column", flexShrink: 0, width: 1, children: cells }));
15
+ }
@@ -17,17 +17,23 @@ import type { Theme } from '../theme.js';
17
17
  import type { TermCapabilities } from '../capabilities.js';
18
18
  import { type ScrollIntent } from './scroll.js';
19
19
  export interface ScrollViewportProps {
20
- /** Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires. */
20
+ /**
21
+ * Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires.
22
+ * `repeat` folds a coalesced wheel burst into one state update (defaults 1).
23
+ */
21
24
  intent?: {
22
25
  kind: ScrollIntent;
23
26
  nonce: number;
27
+ repeat?: number;
24
28
  };
25
29
  /** Bumped by the parent to force a pin to the newest output (e.g. on submit). */
26
30
  pinToBottomNonce?: number;
27
31
  /** Derived display value for the status bar's `↑N`. Must be referentially stable. */
28
32
  onScrolledLinesChange?: (lines: number) => void;
33
+ /** Reserve the right-edge scroll-position rail (mouse-wheel §4.8). */
34
+ showScrollIndicator?: boolean;
29
35
  theme: Theme;
30
36
  caps: TermCapabilities;
31
37
  children: React.ReactNode;
32
38
  }
33
- export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
39
+ export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, showScrollIndicator, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
@@ -16,10 +16,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
17
17
  import { Box, Text, measureElement, useStdout } from 'ink';
18
18
  import { pickGlyphs } from '../glyphs.js';
19
- import { applyScroll, clampScroll, } from './scroll.js';
19
+ import { applyScrollTimes, clampScroll, } from './scroll.js';
20
+ import { thumbRange } from './scroll-indicator.js';
21
+ import { MIN_INDICATOR_COLS, ScrollIndicator } from './ScrollIndicator.js';
20
22
  /** Below this the full hint sentence wraps and eats a second viewport row. */
21
23
  const TERSE_HINT_COLS = 42;
22
- export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, theme, caps, children, }) {
24
+ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesChange, showScrollIndicator = false, theme, caps, children, }) {
23
25
  const { stdout } = useStdout();
24
26
  const cols = stdout?.columns ?? 80;
25
27
  const glyphs = pickGlyphs(caps);
@@ -38,12 +40,26 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
38
40
  const content = innerRef.current ? measureElement(innerRef.current).height : 0;
39
41
  setMetrics((prev) => (prev.viewport === viewport && prev.content === content ? prev : { viewport, content }));
40
42
  });
43
+ // I-9: seeded from the INCOMING nonce during the first render, so the effect
44
+ // below cannot apply an intent on the mount that first observes it. A
45
+ // `useEffect([nonce])` fires on mount as well as on change, and this
46
+ // component is unmounted whenever an overlay opens (`App.tsx` swaps it out) —
47
+ // without the guard, closing an overlay replays whatever wheel gesture was in
48
+ // flight when it opened, and the transcript jumps a page on its own. That is
49
+ // R-P1-7, which this codebase has already paid for once through the keyboard
50
+ // path; the overlay-swallowing rule in `App`'s key handler becomes a SECOND
51
+ // line of defence rather than the only one.
52
+ const lastAppliedNonce = useRef(intent?.nonce);
41
53
  const intentNonce = intent?.nonce ?? 0;
42
54
  useEffect(() => {
43
55
  const current = intentRef.current;
44
56
  if (!current)
45
57
  return;
46
- setScroll((prev) => applyScroll(prev, current.kind, metricsRef.current));
58
+ if (current.nonce === lastAppliedNonce.current)
59
+ return;
60
+ lastAppliedNonce.current = current.nonce;
61
+ const repeat = current.repeat ?? 1;
62
+ setScroll((prev) => applyScrollTimes(prev, current.kind, metricsRef.current, repeat));
47
63
  // Keyed on the nonce alone: `intent` is a fresh object on every parent
48
64
  // render, so depending on it directly would re-scroll on every keystroke.
49
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -61,7 +77,11 @@ export function ScrollViewport({ intent, pinToBottomNonce = 0, onScrolledLinesCh
61
77
  // what "N new lines you have not scrolled down to" means.
62
78
  const hiddenBelow = offset;
63
79
  const showHint = hiddenBelow > 0;
64
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: children }) }), showHint && (
80
+ // Suppressed below `MIN_INDICATOR_COLS`, where a column of content is worth
81
+ // more than the affordance. The routing never reads this flag, which is what
82
+ // keeps the indicator independently revertible (§13-Q3).
83
+ const indicatorVisible = showScrollIndicator && cols >= MIN_INDICATOR_COLS;
84
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { ref: clipRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Box, { ref: innerRef, flexDirection: "column", flexShrink: 0, marginTop: -shiftUp, children: children }) }), indicatorVisible && (_jsx(ScrollIndicator, { rows: metrics.viewport, thumb: thumbRange(metrics.viewport, metrics.content, offset), theme: theme, caps: caps }))] }), showHint && (
65
85
  // Not an overlay — Ink has no absolute positioning or z-index. This takes
66
86
  // the viewport's last row, trading one line of content for a hint that
67
87
  // cannot drift. Cheaper than stacking with negative margins.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Wheel band hit-testing (mouse-wheel-region-routing §4.5) — pure, React-free.
3
+ *
4
+ * The requirement is stated in terms of REGIONS ("scrolling over the agent area
5
+ * scrolls the transcript; scrolling over the input box steps prompt history"),
6
+ * so the router needs to turn a reported terminal row into one of two bands.
7
+ *
8
+ * The boundary is MEASURED, never computed from `budget.ts`. That arithmetic
9
+ * describes the PLANNED chrome, but the real bottom chrome grows and shrinks at
10
+ * run time: the toast row comes and goes, the hint row is dropped below
11
+ * `HINT_MIN_ROWS`, and the autocomplete popup adds up to nine rows. Routing off
12
+ * the plan would put the boundary in the wrong place exactly when the popup is
13
+ * open — the moment precision matters most (D-5 / D-16).
14
+ */
15
+ export type WheelBand = 'content' | 'composer';
16
+ export interface FrameGeometry {
17
+ /**
18
+ * 1-based row of the frame's first row. 1 in the alternate screen.
19
+ *
20
+ * This is DERIVED, not assumed, and it has a named dependency: it holds
21
+ * because the full-screen transcript renders no `<Static>`
22
+ * (`Transcript.tsx`), so Ink's `log-update` writes `frameHeight(rows) = rows-1`
23
+ * lines plus a newline into a `rows`-row buffer — nothing scrolls, and the
24
+ * frame sits at rows `1 … rows-1` with the cursor parked on the undrawn last
25
+ * row. Reintroducing `<Static>` into the full-screen path pushes the frame
26
+ * down the screen and silently biases every hit test; whoever does that has
27
+ * to change `top` here at the same time.
28
+ */
29
+ top: number;
30
+ /** Rows the frame occupies — `frameHeight(rows)`. */
31
+ height: number;
32
+ /** Measured rows of the bottom chrome (toast + composer + status). */
33
+ bottom: number;
34
+ }
35
+ /**
36
+ * The "I do not know where the frame is" value.
37
+ *
38
+ * `App` writes it into the geometry ref on every render path that does NOT
39
+ * render the full-screen `AppShell` — the terminal-too-small placeholder and
40
+ * inline mode — so a stale measurement can never outlive the frame it
41
+ * described. There is deliberately no arithmetic fallback: an unmeasured frame
42
+ * routes to `'content'`, which is the band that cannot destroy anything, and
43
+ * one invisible frame after a resize is a better failure than a wrong band
44
+ * (§4.5, P1-5 / D-16).
45
+ */
46
+ export declare const UNMEASURED_GEOMETRY: FrameGeometry;
47
+ /**
48
+ * Which band does terminal row `y` belong to?
49
+ *
50
+ * Fails toward `'content'`: the whole point of this feature is that a wheel
51
+ * notch must never edit the user's draft (I-3), so every degenerate input —
52
+ * nothing measured yet, a row outside the frame, a chrome measurement that
53
+ * swallowed the screen — resolves to the harmless direction.
54
+ */
55
+ export declare function hitTestWheel(y: number, geometry: FrameGeometry): WheelBand;