@aragon-agent/cli 0.6.24 → 0.6.26

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 (77) hide show
  1. package/CHANGELOG.md +1924 -1786
  2. package/README.md +2730 -2562
  3. package/dist/agent/controller.d.ts +49 -3
  4. package/dist/agent/controller.js +78 -3
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/reducer.d.ts +22 -0
  12. package/dist/agent/reducer.js +49 -14
  13. package/dist/cli.js +214 -2
  14. package/dist/commands/branch-command.d.ts +61 -0
  15. package/dist/commands/branch-command.js +117 -0
  16. package/dist/commands/builtins.js +62 -40
  17. package/dist/commands/clear-command.d.ts +27 -0
  18. package/dist/commands/clear-command.js +107 -0
  19. package/dist/commands/registry.d.ts +48 -1
  20. package/dist/commands/session-command.d.ts +54 -0
  21. package/dist/commands/session-command.js +109 -0
  22. package/dist/config/load.d.ts +15 -0
  23. package/dist/config/model-profiles.d.ts +8 -0
  24. package/dist/config/model-profiles.js +10 -0
  25. package/dist/exec/index.js +10 -1
  26. package/dist/logging/logger.d.ts +1 -1
  27. package/dist/proc/terminal-plan.d.ts +155 -0
  28. package/dist/proc/terminal-plan.js +360 -0
  29. package/dist/session/branch-invocation.d.ts +83 -0
  30. package/dist/session/branch-invocation.js +109 -0
  31. package/dist/session/branch-launch.d.ts +70 -0
  32. package/dist/session/branch-launch.js +248 -0
  33. package/dist/session/checkpoint.d.ts +257 -0
  34. package/dist/session/checkpoint.js +512 -0
  35. package/dist/session/clear-archive.d.ts +51 -0
  36. package/dist/session/clear-archive.js +160 -0
  37. package/dist/session/cli-commands.d.ts +2 -0
  38. package/dist/session/cli-commands.js +36 -5
  39. package/dist/session/identity.d.ts +72 -0
  40. package/dist/session/identity.js +148 -0
  41. package/dist/session/limits.d.ts +66 -0
  42. package/dist/session/limits.js +66 -0
  43. package/dist/session/persist.d.ts +30 -5
  44. package/dist/session/startup-session.d.ts +107 -0
  45. package/dist/session/startup-session.js +315 -0
  46. package/dist/session/store.d.ts +60 -1
  47. package/dist/session/store.js +95 -6
  48. package/dist/todo/types.d.ts +5 -3
  49. package/dist/ui/App.d.ts +54 -2
  50. package/dist/ui/App.js +154 -28
  51. package/dist/ui/Header.js +8 -16
  52. package/dist/ui/StatusBar.d.ts +8 -0
  53. package/dist/ui/StatusBar.js +12 -3
  54. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  55. package/dist/ui/entries/CompactionCard.js +5 -4
  56. package/dist/ui/layout/header-hints.d.ts +68 -0
  57. package/dist/ui/layout/header-hints.js +138 -0
  58. package/dist/ui/layout/session-chip.d.ts +52 -0
  59. package/dist/ui/layout/session-chip.js +64 -0
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +8 -1
  62. package/dist/ui/overlays/HelpOverlay.js +17 -5
  63. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  64. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  65. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  66. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  67. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  68. package/dist/ui/overlays/SettingsScreen.js +34 -2
  69. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  70. package/dist/ui/overlays/model-probe-view.js +225 -0
  71. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  72. package/dist/ui/overlays/model-profile-state.js +10 -0
  73. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  74. package/dist/ui/overlays/use-model-probe.js +109 -0
  75. package/dist/ui/session-bridge.d.ts +26 -0
  76. package/dist/ui/session-bridge.js +32 -0
  77. package/package.json +2 -2
@@ -5,6 +5,7 @@ import { Box, Text } from 'ink';
5
5
  import { pickGlyphs } from './glyphs.js';
6
6
  import { liveSpinner } from './ActivityLine.js';
7
7
  import { planPrimaryStatusFields } from './layout/status-layout.js';
8
+ import { planSessionChip } from './layout/session-chip.js';
8
9
  /** An invisible leading byte change forces Ink to repaint on Ctrl+L. */
9
10
  const redrawChar = (nonce) => nonce % 2 ? String.fromCharCode(0x00a0) : ' ';
10
11
  export function StatusBar(props) {
@@ -13,7 +14,15 @@ export function StatusBar(props) {
13
14
  const active = phase !== 'idle' || !!props.compactionActive?.inFlight;
14
15
  const glyph = pickGlyphs(props.caps).spinnerStill;
15
16
  const spinner = active ? liveSpinner(props.reducedMotion ?? false, props.caps) : null;
17
+ // THE CHIP IS PLANNED FIRST, and the plan then feeds the field budget: the two
18
+ // cannot disagree about how wide the terminal is if only one of them is asked.
19
+ const chip = planSessionChip({
20
+ columns: cols,
21
+ ...(props.sessionId ? { sessionId: props.sessionId } : {}),
22
+ });
23
+ const chipCells = chip.visible ? chip.cells + chip.gap : 0;
16
24
  const plan = planPrimaryStatusFields({
25
+ reserveRight: chipCells,
17
26
  columns: cols, phase, pendingCount: props.pendingSteering?.length ?? 0,
18
27
  context: props.context, usageTotal: props.usageTotal,
19
28
  thinkingLevel: props.thinkingLevel, elapsedMs: props.elapsedMs, tokPerSec: props.tokPerSec,
@@ -29,7 +38,7 @@ export function StatusBar(props) {
29
38
  const tones = { normal: props.theme.primary, muted: props.theme.muted,
30
39
  warning: props.theme.noticeWarn, error: props.theme.noticeError,
31
40
  accent: props.theme.accent };
32
- return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), plan.fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: plan.separatorCells, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: plan.separator }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], wrap: "truncate", children: item.id === 'phase' && active
33
- ? _jsxs(_Fragment, { children: [spinner ?? glyph, item.text.slice(glyph.length)] })
34
- : item.text }) })] }, item.id))] });
41
+ return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), _jsx(Box, { width: Math.max(0, cols - 1 - chipCells), flexShrink: 0, flexDirection: "row", overflow: "hidden", children: plan.fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: plan.separatorCells, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: plan.separator }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], wrap: "truncate", children: item.id === 'phase' && active
42
+ ? _jsxs(_Fragment, { children: [spinner ?? glyph, item.text.slice(glyph.length)] })
43
+ : item.text }) })] }, item.id)) }), chip.visible && _jsx(Box, { width: chipCells, flexShrink: 0, justifyContent: "flex-end", overflow: "hidden", children: _jsx(Text, { color: props.theme.muted, wrap: "truncate", children: chip.text }) })] });
35
44
  }
@@ -14,10 +14,11 @@
14
14
  * kind — without it the card renders a hint that does nothing and expands an
15
15
  * older tool card instead (C-15 / P1-8 / AC-21).
16
16
  *
17
- * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
18
- * empties the transcript view but not `messages`, so this card can legitimately
19
- * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
20
- * "above" or "earlier in this transcript".
17
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
18
+ * `/clear screen` empties the transcript view but not `messages`, so this card
19
+ * can legitimately report a message count the visible rows do not add up to.
20
+ * Nothing here says "above" or "earlier in this transcript". `/clear` itself is
21
+ * the conversation boundary now: it drops the history and this card with it.
21
22
  */
22
23
  import React from 'react';
23
24
  import type { Theme } from '../theme.js';
@@ -15,10 +15,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  * kind — without it the card renders a hint that does nothing and expands an
16
16
  * older tool card instead (C-15 / P1-8 / AC-21).
17
17
  *
18
- * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
19
- * empties the transcript view but not `messages`, so this card can legitimately
20
- * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
21
- * "above" or "earlier in this transcript".
18
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
19
+ * `/clear screen` empties the transcript view but not `messages`, so this card
20
+ * can legitimately report a message count the visible rows do not add up to.
21
+ * Nothing here says "above" or "earlier in this transcript". `/clear` itself is
22
+ * the conversation boundary now: it drops the history and this card with it.
22
23
  */
23
24
  import React from 'react';
24
25
  import { Box, Text } from 'ink';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The right-hand hint budget for the header row (tui-branch-terminal-shortcut 3.8).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * WHY THIS IS A PLANNER AND NOT ANOTHER TERNARY IN THE JSX (R-0-4): the header's
7
+ * right side used to be one hand-computed width constant (`detailCells = indexHint
8
+ * ? 32 : 18`). Adding a third affordance to that arithmetic produces either a
9
+ * clipped hint or a pushed-out word mark on a 72-column terminal, and NO TEST
10
+ * WOULD SEE IT - the only thing lost is trailing characters. `ui/layout/session-chip.ts`
11
+ * already set the precedent for the same problem: the chip has to participate in
12
+ * one width budget rather than be appended and hoped for.
13
+ *
14
+ * THE RULE IS ONE COMPARISON. `budget = columns - minLeftCells`, then take the
15
+ * first ladder rung whose `cells` fits. There is no second magic constant, and the
16
+ * threshold table in the spec is what that comparison WORKS OUT TO, not an extra
17
+ * branch.
18
+ *
19
+ * WHY `minLeftCells` IS 24: the word mark is 13 cells and `provider:model` + cwd +
20
+ * the key symbol need at least 11 more to stay readable. It is lower than
21
+ * `status-layout`'s 34 because the top-left half has a brand to lose while the
22
+ * bottom-left half has a phase that must not disappear.
23
+ */
24
+ /**
25
+ * Bounds for the hint budget.
26
+ *
27
+ * WHY THEY LIVE IN THIS FILE (R-21, accepted): `CLAUDE.md`'s general rule is that
28
+ * structural bounds live in the subsystem's `limits.ts`, and `ui/layout/` has none
29
+ * - `budget.ts` keeps `HINT_MIN_ROWS` beside its own module for the same reason.
30
+ * This module has exactly one consumer. If `ui/layout/` ever grows a real
31
+ * `limits.ts`, these three constants should move there together; this comment is
32
+ * that future migration's note.
33
+ */
34
+ export declare const HEADER_HINT_LIMITS: {
35
+ /** Cells the LEFT half of the header must keep (`AragonAgent` is 13 of them). */
36
+ readonly minLeftCells: 24;
37
+ /** Fixed spacing between two hint clauses. */
38
+ readonly gapCells: 2;
39
+ };
40
+ export type HeaderHintId = 'branch' | 'index' | 'detail';
41
+ export interface HeaderHintClause {
42
+ id: HeaderHintId;
43
+ text: string;
44
+ }
45
+ export interface HeaderHintPlan {
46
+ /** Clauses left to right (including the overlay's `Details on/off` replacement). */
47
+ clauses: HeaderHintClause[];
48
+ /** The whole right-hand string, scroll chip included with its trailing spacing. */
49
+ text: string;
50
+ /** Total cells of the right column; `Header` uses it to size the left one. */
51
+ cells: number;
52
+ /** Cells taken by the scroll chip (0 when there is none). */
53
+ scrollCells: number;
54
+ /** True while an overlay owns the keyboard: only the detail word remains. */
55
+ detailsOnly: boolean;
56
+ }
57
+ /**
58
+ * Plan the right-hand hints.
59
+ *
60
+ * The four inputs are exactly the props `Header` already receives - the call site
61
+ * does not change, only the arithmetic behind it (spec 3.11 step 5).
62
+ */
63
+ export declare function planHeaderHints(input: {
64
+ columns: number;
65
+ statusExpanded: boolean;
66
+ overlayOpen: boolean;
67
+ scrolledLines: number;
68
+ }): HeaderHintPlan;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The right-hand hint budget for the header row (tui-branch-terminal-shortcut 3.8).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * WHY THIS IS A PLANNER AND NOT ANOTHER TERNARY IN THE JSX (R-0-4): the header's
7
+ * right side used to be one hand-computed width constant (`detailCells = indexHint
8
+ * ? 32 : 18`). Adding a third affordance to that arithmetic produces either a
9
+ * clipped hint or a pushed-out word mark on a 72-column terminal, and NO TEST
10
+ * WOULD SEE IT - the only thing lost is trailing characters. `ui/layout/session-chip.ts`
11
+ * already set the precedent for the same problem: the chip has to participate in
12
+ * one width budget rather than be appended and hoped for.
13
+ *
14
+ * THE RULE IS ONE COMPARISON. `budget = columns - minLeftCells`, then take the
15
+ * first ladder rung whose `cells` fits. There is no second magic constant, and the
16
+ * threshold table in the spec is what that comparison WORKS OUT TO, not an extra
17
+ * branch.
18
+ *
19
+ * WHY `minLeftCells` IS 24: the word mark is 13 cells and `provider:model` + cwd +
20
+ * the key symbol need at least 11 more to stay readable. It is lower than
21
+ * `status-layout`'s 34 because the top-left half has a brand to lose while the
22
+ * bottom-left half has a phase that must not disappear.
23
+ */
24
+ /**
25
+ * Bounds for the hint budget.
26
+ *
27
+ * WHY THEY LIVE IN THIS FILE (R-21, accepted): `CLAUDE.md`'s general rule is that
28
+ * structural bounds live in the subsystem's `limits.ts`, and `ui/layout/` has none
29
+ * - `budget.ts` keeps `HINT_MIN_ROWS` beside its own module for the same reason.
30
+ * This module has exactly one consumer. If `ui/layout/` ever grows a real
31
+ * `limits.ts`, these three constants should move there together; this comment is
32
+ * that future migration's note.
33
+ */
34
+ export const HEADER_HINT_LIMITS = {
35
+ /** Cells the LEFT half of the header must keep (`AragonAgent` is 13 of them). */
36
+ minLeftCells: 24,
37
+ /** Fixed spacing between two hint clauses. */
38
+ gapCells: 2,
39
+ };
40
+ /** The verbatim clause text (spec 3.8, appendix A). */
41
+ const BRANCH_CLAUSE = { collapsed: '^L branch', expanded: '^L new branch' };
42
+ const INDEX_CLAUSE = { collapsed: '^I Index', expanded: '^I build index' };
43
+ const DETAIL_CLAUSE = { collapsed: '^G more', expanded: '^G less' };
44
+ /** L3 gives the key name and nothing else: discoverability at the last width. */
45
+ const BRANCH_KEY_ONLY = '^L';
46
+ /** Overlay wording, kept exactly as `Header.tsx` had it. */
47
+ const DETAILS_ON = 'Details on';
48
+ const DETAILS_OFF = 'Details off';
49
+ /** Above this, the chip stops counting and saturates (`^9999+`), as it always has. */
50
+ const SCROLL_CHIP_MAX = 9999;
51
+ /**
52
+ * Plan the right-hand hints.
53
+ *
54
+ * The four inputs are exactly the props `Header` already receives - the call site
55
+ * does not change, only the arithmetic behind it (spec 3.11 step 5).
56
+ */
57
+ export function planHeaderHints(input) {
58
+ // NORMALISED HERE, NOT TRUSTED (R-14). `Header` defaults to 80, but a test or a
59
+ // future caller may pass anything; `budget.ts::dimension()` already set the rule
60
+ // (non-finite or < 1 falls back, everything else floors) and this follows it.
61
+ const columns = normalizeDimension(input.columns, 80);
62
+ const scrolledLines = Math.max(0, Math.floor(normalizeDimension(input.scrolledLines, 0)));
63
+ const scrollCells = scrollChip(scrolledLines, input.overlayOpen);
64
+ if (input.overlayOpen) {
65
+ // THE OVERLAY OWNS THE KEYBOARD, so every key hint disappears (D-14's sibling
66
+ // rule, unchanged from the old JSX). The scroll chip stays: it is a position
67
+ // indicator, not an affordance.
68
+ const detail = input.statusExpanded ? DETAILS_ON : DETAILS_OFF;
69
+ return buildPlan([{ id: 'detail', text: detail }], scrollCells, true);
70
+ }
71
+ const ladder = ladderFor(input.statusExpanded);
72
+ const budget = columns - HEADER_HINT_LIMITS.minLeftCells;
73
+ for (const rung of ladder) {
74
+ const plan = buildPlan(rung, scrollCells, false);
75
+ if (plan.cells <= budget)
76
+ return plan;
77
+ }
78
+ // L4 IS UNCONDITIONAL: `^G` is the only entry in this table that may never be
79
+ // dropped - it is how a user who hid the status detail fields gets them back.
80
+ // A header narrower than the last rung still renders; the LEFT column is what
81
+ // shrinks (to zero), and `overflow="hidden"` keeps that from overflowing.
82
+ return buildPlan(ladder[ladder.length - 1] ?? [], scrollCells, false);
83
+ }
84
+ /** The four rungs, widest first. Built per call because the widths are text-derived. */
85
+ function ladderFor(statusExpanded) {
86
+ const branch = {
87
+ id: 'branch',
88
+ text: statusExpanded ? BRANCH_CLAUSE.expanded : BRANCH_CLAUSE.collapsed,
89
+ };
90
+ const index = {
91
+ id: 'index',
92
+ text: statusExpanded ? INDEX_CLAUSE.expanded : INDEX_CLAUSE.collapsed,
93
+ };
94
+ const detail = {
95
+ id: 'detail',
96
+ text: statusExpanded ? DETAIL_CLAUSE.expanded : DETAIL_CLAUSE.collapsed,
97
+ };
98
+ return [
99
+ [branch, index, detail],
100
+ [branch, detail],
101
+ [{ id: 'branch', text: BRANCH_KEY_ONLY }, detail],
102
+ [detail],
103
+ ];
104
+ }
105
+ /**
106
+ * Compose the final string. The chip carries its OWN trailing spacing and is the
107
+ * only source of it (R-8): one space while an overlay is open, two otherwise -
108
+ * copied byte for byte from the expression the header already used.
109
+ */
110
+ function buildPlan(clauses, scrollChipText, detailsOnly) {
111
+ const body = clauses.map((clause) => clause.text).join(' '.repeat(HEADER_HINT_LIMITS.gapCells));
112
+ const text = scrollChipText.length > 0 ? `${scrollChipText}${body}` : body;
113
+ return {
114
+ clauses,
115
+ text,
116
+ cells: text.length,
117
+ scrollCells: scrollChipText.length,
118
+ detailsOnly,
119
+ };
120
+ }
121
+ /**
122
+ * `^<n>` plus its trailing spacing, or `''`.
123
+ *
124
+ * THE SPACING IS PART OF THE CHIP, not something `Header` adds afterwards: with two
125
+ * sources of spacing a reviewer has to read two files to answer "how wide is the
126
+ * chip", and the overlay's single-space case lived in the JSX as a ternary nobody
127
+ * would think to check.
128
+ */
129
+ function scrollChip(scrolledLines, overlayOpen) {
130
+ if (scrolledLines <= 0)
131
+ return '';
132
+ const label = scrolledLines > SCROLL_CHIP_MAX ? `^${SCROLL_CHIP_MAX}+` : `^${scrolledLines}`;
133
+ return label + (overlayOpen ? ' ' : ' ');
134
+ }
135
+ /** The `budget.ts::dimension()` rule, local because that helper is not exported. */
136
+ function normalizeDimension(value, fallback) {
137
+ return Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback;
138
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The session-id chip: pure width planning, no JSX
3
+ * (tui-session-id-resume §3.9 / D-11 / D-12).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY A PLANNER AND NOT A STYLED `<Text>`: the status bar is a cell-by-cell
8
+ * measured row with a six-rung degradation ladder (`status-layout.ts`), not a
9
+ * flex layout.
10
+ * Anything positioned "just to the right" pushes the left half over or covers
11
+ * `phase`, and `phase` is the field a user needs while a run is in flight. The
12
+ * chip therefore spends cells out of the SAME budget as every other field.
13
+ *
14
+ * THE ID IS NEVER TRUNCATED (D-12). A truncated id produces a `--resume` command
15
+ * that is guaranteed to fail, which is strictly worse than showing nothing: the
16
+ * rungs below are, in order, "drop the label" and only then "hide the whole
17
+ * chip", and `/session` reports the full id at any width.
18
+ */
19
+ export declare const SESSION_CHIP_LIMITS: {
20
+ /** The same threshold `Header.tsx` uses for its `^I Index` hint. */
21
+ readonly labelMinCols: 72;
22
+ /** Blank cells between the last status field and the chip. */
23
+ readonly gapCells: 2;
24
+ /**
25
+ * The tightest rung's worst-case width for the fields on the LEFT, summed:
26
+ * phase glyph(1)+label(4)=5, sep 1 -> 6, deferred mode 'C>B' 3 -> 9,
27
+ * sep 1 -> 10, 'C'+'~999%' 6 -> 16, sep 1 -> 17, thinking code 1 -> 18,
28
+ * sep 1 -> 19, '>99M/s' 6 -> 25, sep 1 -> 26, '>999d' 5 -> 31.
29
+ *
30
+ * 34 IS A FLOOR ROUNDED UP, not a measurement: over-estimating hides the chip
31
+ * one column early, which is invisible; under-estimating lets the chip eat the
32
+ * phase field, which is not.
33
+ */
34
+ readonly minStatusCells: 34;
35
+ };
36
+ export interface SessionChipPlan {
37
+ visible: boolean;
38
+ text: string;
39
+ cells: number;
40
+ gap: number;
41
+ }
42
+ /**
43
+ * Plan the chip for `columns` and this session's id.
44
+ *
45
+ * Inputs are the same ones the status row already has, so the two plans cannot
46
+ * disagree about the terminal: `StatusBar` computes `chip` first and hands
47
+ * `chip.cells + chip.gap` to `planPrimaryStatusFields` as `reserveRight`.
48
+ */
49
+ export declare function planSessionChip(input: {
50
+ columns: number;
51
+ sessionId?: string | null;
52
+ }): SessionChipPlan;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The session-id chip: pure width planning, no JSX
3
+ * (tui-session-id-resume §3.9 / D-11 / D-12).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY A PLANNER AND NOT A STYLED `<Text>`: the status bar is a cell-by-cell
8
+ * measured row with a six-rung degradation ladder (`status-layout.ts`), not a
9
+ * flex layout.
10
+ * Anything positioned "just to the right" pushes the left half over or covers
11
+ * `phase`, and `phase` is the field a user needs while a run is in flight. The
12
+ * chip therefore spends cells out of the SAME budget as every other field.
13
+ *
14
+ * THE ID IS NEVER TRUNCATED (D-12). A truncated id produces a `--resume` command
15
+ * that is guaranteed to fail, which is strictly worse than showing nothing: the
16
+ * rungs below are, in order, "drop the label" and only then "hide the whole
17
+ * chip", and `/session` reports the full id at any width.
18
+ */
19
+ import stringWidth from 'string-width';
20
+ export const SESSION_CHIP_LIMITS = {
21
+ /** The same threshold `Header.tsx` uses for its `^I Index` hint. */
22
+ labelMinCols: 72,
23
+ /** Blank cells between the last status field and the chip. */
24
+ gapCells: 2,
25
+ /**
26
+ * The tightest rung's worst-case width for the fields on the LEFT, summed:
27
+ * phase glyph(1)+label(4)=5, sep 1 -> 6, deferred mode 'C>B' 3 -> 9,
28
+ * sep 1 -> 10, 'C'+'~999%' 6 -> 16, sep 1 -> 17, thinking code 1 -> 18,
29
+ * sep 1 -> 19, '>99M/s' 6 -> 25, sep 1 -> 26, '>999d' 5 -> 31.
30
+ *
31
+ * 34 IS A FLOOR ROUNDED UP, not a measurement: over-estimating hides the chip
32
+ * one column early, which is invisible; under-estimating lets the chip eat the
33
+ * phase field, which is not.
34
+ */
35
+ minStatusCells: 34,
36
+ };
37
+ /** `{visible:false}` - the shape every caller can cheaply branch on. */
38
+ const HIDDEN = { visible: false, text: '', cells: 0, gap: 0 };
39
+ /**
40
+ * Plan the chip for `columns` and this session's id.
41
+ *
42
+ * Inputs are the same ones the status row already has, so the two plans cannot
43
+ * disagree about the terminal: `StatusBar` computes `chip` first and hands
44
+ * `chip.cells + chip.gap` to `planPrimaryStatusFields` as `reserveRight`.
45
+ */
46
+ export function planSessionChip(input) {
47
+ // No identity (the default build, a test, or a host with no session) is the
48
+ // `undefined` case, and it must cost nothing: reserveRight stays 0 and the
49
+ // status row's output is byte-identical to the pre-feature build (I-6).
50
+ if (!input.sessionId)
51
+ return HIDDEN;
52
+ const columns = Number.isFinite(input.columns) ? Math.floor(input.columns) : 80;
53
+ const id = input.sessionId;
54
+ const gap = SESSION_CHIP_LIMITS.gapCells;
55
+ const fits = (text) => columns - 1 - gap - stringWidth(text) >= SESSION_CHIP_LIMITS.minStatusCells;
56
+ // Widest form first, then drop the label, then hide: the order is the decision.
57
+ const labelled = `id ${id}`;
58
+ if (columns >= SESSION_CHIP_LIMITS.labelMinCols && fits(labelled)) {
59
+ return { visible: true, text: labelled, cells: stringWidth(labelled), gap };
60
+ }
61
+ if (fits(id))
62
+ return { visible: true, text: id, cells: stringWidth(id), gap };
63
+ return HIDDEN;
64
+ }
@@ -44,6 +44,16 @@ export interface StatusLayoutInput {
44
44
  inFlight: boolean;
45
45
  };
46
46
  scrolledLines?: number;
47
+ /**
48
+ * Cells already spent at the RIGHT end of this same row
49
+ * (tui-session-id-resume 3.10 / I-6).
50
+ *
51
+ * The session-id chip is not a layer above this row: it takes its cells out of
52
+ * the SAME budget, because the row is measured rather than laid out and
53
+ * anything overlapping it eats `phase` first. ABSENT MEANS 0, which is what
54
+ * keeps every existing caller's plan byte-identical (I-6).
55
+ */
56
+ reserveRight?: number;
47
57
  }
48
58
  export interface StatusPrimaryInput extends StatusLayoutInput {
49
59
  feedback?: StatusFeedback;
@@ -113,7 +113,14 @@ function modeFieldText(mode, pendingMode, full) {
113
113
  }
114
114
  /** Every field survives each width rung; only the redraw carrier is outside this plan. */
115
115
  export function planPrimaryStatusFields(input) {
116
- const budget = Math.max(0, (Number.isFinite(input.columns) ? Math.floor(input.columns) : 80) - 1);
116
+ const columns = Number.isFinite(input.columns) ? Math.floor(input.columns) : 80;
117
+ // GUARDED LIKE `columns` ABOVE, and for the same reason: `Math.max(0, NaN)`
118
+ // is `NaN`, which would make every `cells > budget` comparison false and
119
+ // silently pin this row at its widest rung - the opposite of reserving space.
120
+ const reserved = typeof input.reserveRight === 'number' && Number.isFinite(input.reserveRight)
121
+ ? Math.max(0, Math.floor(input.reserveRight))
122
+ : 0;
123
+ const budget = Math.max(0, columns - 1 - reserved);
117
124
  const labels = phaseLabels(input);
118
125
  const glyphText = cleanStatusText(input.spinner ?? '-');
119
126
  const glyph = stringWidth(glyphText) === 1 ? glyphText : '-';
@@ -21,7 +21,11 @@ function keyRows(times) {
21
21
  // with a draft x2 clears the input and x3 exits; an empty input
22
22
  // keeps the previous x2 exit.
23
23
  ['Ctrl+C', `Draft: ${times}2 clear input, ${times}3 exit. Empty: ${times}2 exit`],
24
- ['Ctrl+L', 'Redraw the frame'],
24
+ // Ctrl+L IS THE BRANCH KEY NOW (tui-branch-terminal-shortcut D-1) and the
25
+ // redraw moved to Ctrl+R. Both rows are listed, because the migration is the
26
+ // one thing an existing user will press by muscle memory and be surprised by.
27
+ ['Ctrl+L', 'Open a new terminal on this session (the branch gets its own id)'],
28
+ ['Ctrl+R', 'Redraw the frame'],
25
29
  ['Ctrl+T', 'Show/hide thinking (off by default)'],
26
30
  ['Ctrl+I', 'Build the project index (bundled skill; asks first; needs keyboard enhancement)'],
27
31
  ['Ctrl+G', 'Expand/collapse status details; disabled while an overlay is open'],
@@ -68,11 +72,19 @@ const COMMANDS = [
68
72
  'Todo planning: status, switches, the rail, follow-through',
69
73
  ],
70
74
  ['/tools', 'List active tools'],
71
- ['/clear', 'Clear the visible transcript and the todo panel'],
72
- ['/reset', 'New conversation'],
75
+ // `/clear` IS NOT "CLEAR THE SCREEN" ANY MORE (clear-command-context-reset D-1).
76
+ // Two rows, not one, because the intentions differ: `/clear` starts a NEW
77
+ // CONVERSATION (interrupt the run, archive the old one, drop the history), while
78
+ // `/clear screen` only wipes what is on screen. A user who wants the second has
79
+ // to be able to see that it exists before reaching for the first.
80
+ ['/clear', 'Start a new conversation: archive, then clear context and transcript'],
81
+ ['/clear screen', 'Clear the visible transcript only; keeps the conversation'],
82
+ ['/reset', 'Alias of /clear'],
73
83
  ['/cwd [dir]', 'Show or change the tool working directory'],
74
- ['/save [file]', 'Save the session to JSON'],
75
- ['/resume [file]', 'Load a saved session'],
84
+ ['/save [file]', 'Export the session to JSON (a file you keep)'],
85
+ ['/resume [file]', 'Load a saved session; this session moves to a new id'],
86
+ ['/session [copy|pin]', 'Show this session id and file; copy the resume command; pin it'],
87
+ ['/branch', 'Same as Ctrl+L: branch into a new terminal'],
76
88
  ['/copy', 'Copy last answer; native completion confirmed, OSC 52 request unconfirmed'],
77
89
  ['/queue', 'Read every pending message in full; no replay or queue mutation'],
78
90
  ['/mouse [on|off]', 'Release the mouse to your terminal, or take it back'],
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { type ProfileFrameProps } from './ModelProfilePicker.js';
3
+ import { type DiscoveryView } from './model-probe-view.js';
4
+ interface ModelDiscoveryListProps extends ProfileFrameProps {
5
+ provider: string;
6
+ /** The draft's current model: the cursor starts on it when the list has it. */
7
+ currentModel: string;
8
+ view: DiscoveryView;
9
+ busy: boolean;
10
+ onChoose: (model: string) => void;
11
+ onRetry: () => void;
12
+ onBack: () => void;
13
+ onCancelProbe?: () => void;
14
+ }
15
+ /**
16
+ * The discovered-model page (model-profile-connection-test §3.5).
17
+ *
18
+ * Pure display plus input: it receives the discovery outcome and three intents and
19
+ * issues no request itself. No spinner - the census of animated sites is a closed
20
+ * list, and this overlay can be open while a run is in flight.
21
+ */
22
+ export declare function ModelDiscoveryList(props: ModelDiscoveryListProps): React.ReactElement;
23
+ export {};
@@ -0,0 +1,161 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Text, useInput } from 'ink';
4
+ import { PROBE_LIMITS } from '../../agent/model-probe-limits.js';
5
+ import { pickGlyphs } from '../glyphs.js';
6
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
7
+ import { stripPasteFrames } from '../paste-frames.js';
8
+ import { stripEnterFrames } from '../enter-frames.js';
9
+ import { clipProfileText, useProfileScroll } from './ModelProfilePicker.js';
10
+ import { rankCandidates, windowLabel, clipName, LIST_ID_CHARS } from './model-probe-view.js';
11
+ function matches(candidate, query) {
12
+ const needle = query.toLocaleLowerCase();
13
+ return candidate.id.toLocaleLowerCase().includes(needle)
14
+ || candidate.name.toLocaleLowerCase().includes(needle);
15
+ }
16
+ /** One row: id column, name column, window right-aligned inside the frame budget. */
17
+ function listRowText(candidate, cols) {
18
+ const width = Math.max(20, cols - 8);
19
+ const label = windowLabel(candidate.contextWindow);
20
+ const idWidth = Math.max(10, Math.min(LIST_ID_CHARS, width - label.length - 14));
21
+ const nameWidth = Math.max(4, width - idWidth - label.length - 2);
22
+ const id = [...candidate.id].slice(0, idWidth).join('').padEnd(idWidth);
23
+ // `clipName` caps the NAME COLUMN itself, so a vendor that ships a paragraph in
24
+ // `name` cannot push the window label out of a wide frame either.
25
+ const name = [...clipName(candidate.name)].slice(0, nameWidth).join('').padEnd(nameWidth);
26
+ return clipProfileText(`${id} ${name} ${label}`, width);
27
+ }
28
+ /** What the page says when there is no row to select. */
29
+ function emptyLines(busy, provider, view) {
30
+ if (busy)
31
+ return [`Discovering ${provider} models...`];
32
+ if (view.kind === 'discovery' && !view.outcome.ok) {
33
+ return [view.outcome.detail ?? 'The model list could not be fetched.',
34
+ 'Fix: check the base URL and key, then press r to retry.'];
35
+ }
36
+ if (view.kind === 'discovery') {
37
+ return ['No models returned. Press r to retry, Esc to go back.',
38
+ 'Test the connection for a precise reason.'];
39
+ }
40
+ return ['No models discovered yet. Press r to retry, Esc to go back.'];
41
+ }
42
+ function buildRows(props, view, glyphs) {
43
+ const rows = [];
44
+ if (view.searchable) {
45
+ rows.push(_jsxs(Text, { wrap: "truncate", color: props.theme.primary, children: [view.searching ? `${glyphs.caret} ` : ' ', "Search: ", view.query || '(/ to search)'] }, "search"));
46
+ }
47
+ if (view.shown.length === 0) {
48
+ const lines = view.query.length > 0
49
+ ? [`No model matches "${view.query}". Esc clears the search.`]
50
+ : emptyLines(props.busy, props.provider, props.view);
51
+ for (const line of lines) {
52
+ rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: line }, `empty-${rows.length}`));
53
+ }
54
+ }
55
+ view.shown.forEach((candidate, at) => {
56
+ const focused = at === view.cursor && !view.searching;
57
+ rows.push(_jsxs(Text, { wrap: "truncate", color: at === view.cursor ? props.theme.accent : props.theme.muted, children: [focused ? `${glyphs.caret} ` : ' ', listRowText(candidate, props.cols)] }, candidate.id));
58
+ });
59
+ if (view.truncated) {
60
+ const total = props.view.kind === 'discovery'
61
+ ? props.view.outcome.total ?? view.shown.length : view.shown.length;
62
+ rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: `Showing first ${PROBE_LIMITS.maxListedModels} of ${total}. Narrow with search.` }, "truncated"));
63
+ }
64
+ return rows;
65
+ }
66
+ /**
67
+ * The discovered-model page (model-profile-connection-test §3.5).
68
+ *
69
+ * Pure display plus input: it receives the discovery outcome and three intents and
70
+ * issues no request itself. No spinner - the census of animated sites is a closed
71
+ * list, and this overlay can be open while a run is in flight.
72
+ */
73
+ export function ModelDiscoveryList(props) {
74
+ const [index, setIndex] = useState(0);
75
+ const [query, setQuery] = useState('');
76
+ const [searching, setSearching] = useState(false);
77
+ const glyphs = pickGlyphs(props.caps);
78
+ const candidates = props.view.kind === 'discovery'
79
+ ? props.view.outcome.models : [];
80
+ const ranked = rankCandidates(candidates, props.currentModel);
81
+ const shown = query.length > 0 ? ranked.filter((item) => matches(item, query)) : ranked;
82
+ const searchable = ranked.length > PROBE_LIMITS.searchThreshold;
83
+ const cursor = shown.length > 0 ? Math.min(index, shown.length - 1) : 0;
84
+ // The truncation notice is one row BELOW the last candidate, and `useProfileScroll`
85
+ // only guarantees the FOCUSED row is visible - the same blind spot that hid the
86
+ // editor's result rows (RV-P1-3). Standing on the last row therefore widens the
87
+ // window by one, or `Showing first 400 of ...` could never be read at all.
88
+ const truncatedNotice = props.view.kind === 'discovery' && props.view.outcome.truncated
89
+ && shown.length > 0;
90
+ const revealNotice = truncatedNotice && cursor >= shown.length - 1;
91
+ const offset = useProfileScroll(props, cursor + (searchable ? 1 : 0) + (revealNotice ? 1 : 0));
92
+ // Arrival, not every render: when the outcome lands, put the cursor on the model the
93
+ // draft already names, so "which one am I on" is answerable without scrolling.
94
+ const arrival = props.view.kind === 'discovery'
95
+ ? `${props.view.outcome.connectionId}|${ranked.length}` : 'none';
96
+ useEffect(() => {
97
+ const at = ranked.findIndex((item) => item.id === props.currentModel.trim());
98
+ setIndex(at > 0 ? at : 0);
99
+ }, [arrival]);
100
+ const move = (delta) => {
101
+ if (shown.length > 0)
102
+ setIndex((cursor + delta + shown.length) % shown.length);
103
+ };
104
+ useInput((input, key) => {
105
+ if (props.isActive === false)
106
+ return;
107
+ if (key.escape) {
108
+ if (searching) {
109
+ setSearching(false);
110
+ setQuery('');
111
+ setIndex(0);
112
+ return;
113
+ }
114
+ if (props.busy) {
115
+ props.onCancelProbe?.();
116
+ return;
117
+ }
118
+ props.onBack();
119
+ return;
120
+ }
121
+ if (key.upArrow || (key.tab && key.shift)) {
122
+ move(-1);
123
+ return;
124
+ }
125
+ if (key.downArrow || key.tab) {
126
+ move(1);
127
+ return;
128
+ }
129
+ if (key.return) {
130
+ const picked = shown[cursor];
131
+ if (picked)
132
+ props.onChoose(picked.id);
133
+ return;
134
+ }
135
+ if (searching) {
136
+ if (key.backspace || key.delete) {
137
+ setQuery((value) => [...value].slice(0, -1).join(''));
138
+ setIndex(0);
139
+ }
140
+ else if (input && !key.ctrl && !key.meta) {
141
+ setQuery((value) => value + stripPasteFrames(stripEnterFrames(input)));
142
+ setIndex(0);
143
+ }
144
+ return;
145
+ }
146
+ if (input === '/' && searchable) {
147
+ setSearching(true);
148
+ setIndex(0);
149
+ return;
150
+ }
151
+ // `r` retries ONLY outside the search state: sharing the key with the query would
152
+ // turn one typo into a network request (§3.5 / D-16).
153
+ if (!key.ctrl && !key.meta && input === 'r')
154
+ props.onRetry();
155
+ });
156
+ const hint = props.busy ? 'Esc cancel'
157
+ : searching ? 'Type search | Enter choose | Esc back'
158
+ : `Enter choose${searchable ? ' | / search' : ''} | r retry | Esc back`;
159
+ return _jsx(OverlayFrame, { ...props, scrollOffset: offset, hint: hint, rows: buildRows(props, { query, shown, cursor, searchable, searching,
160
+ truncated: truncatedNotice }, glyphs), title: `Models | ${props.provider} (${shown.length})` });
161
+ }