@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.
- package/CHANGELOG.md +117 -0
- package/README.md +155 -5
- package/dist/agent/controller.d.ts +51 -1
- package/dist/agent/controller.js +177 -2
- package/dist/agent/headless.d.ts +12 -0
- package/dist/agent/headless.js +41 -1
- package/dist/agent/reducer.d.ts +48 -0
- package/dist/agent/reducer.js +91 -2
- package/dist/agent/system-prompt.d.ts +17 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/cli.js +131 -6
- package/dist/commands/builtins.js +82 -0
- package/dist/config/app-paths.d.ts +17 -0
- package/dist/config/app-paths.js +21 -0
- package/dist/config/cli-commands.d.ts +26 -2
- package/dist/config/cli-commands.js +63 -4
- package/dist/config/env.d.ts +2 -2
- package/dist/config/env.js +36 -2
- package/dist/config/history-commands.d.ts +20 -0
- package/dist/config/history-commands.js +71 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/load.js +88 -5
- package/dist/config/migrate-state-out-of-config.d.ts +32 -0
- package/dist/config/migrate-state-out-of-config.js +85 -0
- package/dist/config/prompt-history.d.ts +86 -0
- package/dist/config/prompt-history.js +332 -0
- package/dist/config/schema.d.ts +125 -9
- package/dist/config/schema.js +90 -5
- package/dist/config/store.js +13 -4
- package/dist/config/ui-state.d.ts +52 -0
- package/dist/config/ui-state.js +149 -0
- package/dist/input/mouse-events.d.ts +61 -0
- package/dist/input/mouse-events.js +153 -0
- package/dist/input/stdin-mouse-filter.d.ts +42 -0
- package/dist/input/stdin-mouse-filter.js +153 -0
- package/dist/logging/install.d.ts +85 -0
- package/dist/logging/install.js +94 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.d.ts +16 -0
- package/dist/session/persist.js +19 -1
- package/dist/team/activity.d.ts +67 -0
- package/dist/team/activity.js +130 -0
- package/dist/team/bus.d.ts +190 -0
- package/dist/team/bus.js +366 -0
- package/dist/team/comm-tools.d.ts +61 -0
- package/dist/team/comm-tools.js +221 -0
- package/dist/team/human-queue.d.ts +55 -0
- package/dist/team/human-queue.js +72 -0
- package/dist/team/limits.d.ts +123 -0
- package/dist/team/limits.js +128 -0
- package/dist/team/normalize.d.ts +39 -0
- package/dist/team/normalize.js +106 -0
- package/dist/team/panel-rows.d.ts +48 -0
- package/dist/team/panel-rows.js +75 -0
- package/dist/team/prompt.d.ts +49 -0
- package/dist/team/prompt.js +87 -0
- package/dist/team/report.d.ts +58 -0
- package/dist/team/report.js +205 -0
- package/dist/team/retry.d.ts +48 -0
- package/dist/team/retry.js +56 -0
- package/dist/team/runtime.d.ts +122 -0
- package/dist/team/runtime.js +406 -0
- package/dist/team/subagent.d.ts +90 -0
- package/dist/team/subagent.js +294 -0
- package/dist/team/task-tool.d.ts +47 -0
- package/dist/team/task-tool.js +118 -0
- package/dist/team/types.d.ts +172 -0
- package/dist/team/types.js +13 -0
- package/dist/tools/index.d.ts +48 -2
- package/dist/tools/index.js +26 -1
- package/dist/ui/App.d.ts +9 -1
- package/dist/ui/App.js +140 -21
- package/dist/ui/Composer.d.ts +6 -1
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +15 -1
- package/dist/ui/PromptInput.js +38 -1
- package/dist/ui/StatusBar.d.ts +21 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TeamPanel.d.ts +57 -0
- package/dist/ui/TeamPanel.js +134 -0
- package/dist/ui/Transcript.js +10 -0
- package/dist/ui/entries/TeamCard.d.ts +31 -0
- package/dist/ui/entries/TeamCard.js +39 -0
- package/dist/ui/glyphs.d.ts +22 -0
- package/dist/ui/glyphs.js +10 -0
- package/dist/ui/layout/AppShell.d.ts +20 -1
- package/dist/ui/layout/AppShell.js +39 -4
- package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
- package/dist/ui/layout/ScrollIndicator.js +15 -0
- package/dist/ui/layout/ScrollViewport.d.ts +8 -2
- package/dist/ui/layout/ScrollViewport.js +24 -4
- package/dist/ui/layout/regions.d.ts +55 -0
- package/dist/ui/layout/regions.js +52 -0
- package/dist/ui/layout/scroll-indicator.d.ts +27 -0
- package/dist/ui/layout/scroll-indicator.js +35 -0
- package/dist/ui/layout/scroll.d.ts +9 -0
- package/dist/ui/layout/scroll.js +23 -0
- package/dist/ui/overlays/HelpOverlay.js +14 -0
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/transcript-text.js +20 -0
- package/dist/ui/use-startup-notices.d.ts +11 -1
- package/dist/ui/use-startup-notices.js +29 -2
- package/dist/ui/use-wheel-routing.d.ts +68 -0
- package/dist/ui/use-wheel-routing.js +139 -0
- 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
|
+
}
|
package/dist/ui/Transcript.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/ui/glyphs.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|