@pellux/goodvibes-tui 1.23.0 → 1.24.0
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 +14 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -3
- package/src/cli/bundle-command.ts +4 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/management-utils.ts +4 -0
- package/src/cluster/commands.ts +382 -0
- package/src/cluster/remote-daemon-target.ts +225 -0
- package/src/cluster/render.ts +268 -0
- package/src/core/conversation-line-cache.ts +11 -6
- package/src/core/conversation-render-context.ts +122 -0
- package/src/core/conversation-rendering.ts +86 -89
- package/src/daemon/cli.ts +71 -2
- package/src/daemon/handlers/inbox/index.ts +63 -3
- package/src/daemon/handlers/inbox/poller.ts +54 -8
- package/src/input/command-args-hint.ts +2 -0
- package/src/input/commands/cluster-runtime.ts +53 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-data.ts +6 -0
- package/src/main.ts +3 -0
- package/src/renderer/conversation-surface.ts +23 -6
- package/src/renderer/conversation-tree.ts +142 -93
- package/src/renderer/tool-call.ts +13 -12
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/daemon-handler-composition.ts +80 -0
- package/src/runtime/disposal-wiring.ts +66 -0
- package/src/runtime/durability-services.ts +7 -3
- package/src/runtime/embedded-service-factories.ts +19 -1
- package/src/runtime/process-lifecycle.ts +9 -1
- package/src/runtime/remote-execution-composition.ts +62 -0
- package/src/runtime/services.ts +48 -48
- package/src/verification/verification-ledger.ts +38 -0
- package/src/version.ts +1 -1
|
@@ -132,6 +132,12 @@ export function buildSettingGroups(
|
|
|
132
132
|
// dropped from the workspace entirely — reachable only by editing the
|
|
133
133
|
// config file by hand.
|
|
134
134
|
if (rawCat === 'push' && groups.has('notifications')) groups.get('notifications')!.push(entry);
|
|
135
|
+
// cluster.* decides which node on this network reads the inbox, and it is
|
|
136
|
+
// configured in the same terms the network category already speaks: a
|
|
137
|
+
// group address, a port, a shared phrase, a peer list. Given no category of
|
|
138
|
+
// its own it would match nothing and drop out of the workspace entirely —
|
|
139
|
+
// reachable only by hand-editing a settings file.
|
|
140
|
+
if (rawCat === 'cluster' && groups.has('network')) groups.get('network')!.push(entry);
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
// Synthetic display.themeMode enum (auto|dark|light) — TUI-local, see theme-mode-config.ts.
|
package/src/main.ts
CHANGED
|
@@ -221,6 +221,9 @@ async function main() {
|
|
|
221
221
|
const powerChipSource = createPowerChipSource({ powerManager: ctx.services.powerManager, render: () => render(), isExternalDaemon,
|
|
222
222
|
onPowerEvent: (cb) => uiServices.events.ops.on('OPS_POWER_STATE_CHANGED', cb as never), fetchDaemonPowerState: () => fetchDaemonPowerState({ configManager, homeDirectory, isExternalDaemon }) });
|
|
223
223
|
unsubs.push(powerChipSource.stop, installKeepAwakeRemoteForward({ configManager, homeDirectory, isExternalDaemon }));
|
|
224
|
+
// The interactive process composes a full runtime graph too, and that graph
|
|
225
|
+
// starts pollers. Drained with the rest of the teardown registry on exit.
|
|
226
|
+
unsubs.push(() => { ctx.services.dispose(); });
|
|
224
227
|
|
|
225
228
|
const lifecycle = installProcessLifecycle({
|
|
226
229
|
stdin,
|
|
@@ -2,6 +2,7 @@ import { type Line, createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
|
2
2
|
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
3
3
|
import { LAYOUT } from './layout.ts';
|
|
4
4
|
import { GLYPHS } from './ui-primitives.ts';
|
|
5
|
+
import { treeBranchCol, treeContentCol } from './conversation-tree.ts';
|
|
5
6
|
|
|
6
7
|
export interface ConversationSurfacePalette {
|
|
7
8
|
readonly accent: string;
|
|
@@ -104,7 +105,12 @@ export function renderConversationFragment(
|
|
|
104
105
|
palette: ConversationFragmentPalette,
|
|
105
106
|
indentCols = 0,
|
|
106
107
|
): Line[] {
|
|
107
|
-
|
|
108
|
+
// A tree fragment is a continuation of the row above it, so it starts at that
|
|
109
|
+
// row's content column — which puts its ` ▸ ` prefix glyph in exactly the same
|
|
110
|
+
// column as the parent row's own ` ▸ N lines ` badge. A flush fragment (a user
|
|
111
|
+
// message ghost box) keeps the box margin it has always used.
|
|
112
|
+
const indent = Math.max(0, indentCols);
|
|
113
|
+
const margin = indent > 0 ? treeContentCol(indent) : LAYOUT.USER_BOX_MARGIN;
|
|
108
114
|
const prefixWidth = getDisplayWidth(palette.prefix);
|
|
109
115
|
// The indent is charged to the content budget rather than allowed to push the
|
|
110
116
|
// fragment past the right edge — a narrow terminal shrinks the preview text
|
|
@@ -211,15 +217,26 @@ export function renderConversationStatusLine(
|
|
|
211
217
|
): Line {
|
|
212
218
|
const line = createEmptyLine(width);
|
|
213
219
|
const indent = Math.max(0, options.indentCols ?? 0);
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
// Both columns come from conversation-tree.ts so a flush row and a branch row
|
|
221
|
+
// sit on one grid: the marker where its depth's glyph belongs, the segment run
|
|
222
|
+
// two columns further right.
|
|
223
|
+
const markerCol = treeBranchCol(indent);
|
|
224
|
+
const startCol = treeContentCol(indent);
|
|
216
225
|
const endCol = Math.max(startCol, width - LAYOUT.RIGHT_MARGIN);
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
const marker = options.marker ?? '▌';
|
|
227
|
+
const markerWidth = getDisplayWidth(marker);
|
|
228
|
+
if (markerCol >= 0 && markerCol < width && markerWidth > 0) {
|
|
229
|
+
const markerStyle = {
|
|
219
230
|
fg: options.markerFg ?? '#64748b',
|
|
220
231
|
bg: options.markerBg ?? options.bodyBg ?? '',
|
|
221
232
|
bold: true,
|
|
222
|
-
}
|
|
233
|
+
};
|
|
234
|
+
line[markerCol] = createStyledCell(marker, markerStyle);
|
|
235
|
+
// A double-width marker claims its trailing cell, so the segment run that
|
|
236
|
+
// follows still starts where the grid says it does.
|
|
237
|
+
if (markerWidth > 1 && markerCol + 1 < width) {
|
|
238
|
+
line[markerCol + 1] = createStyledCell('', markerStyle);
|
|
239
|
+
}
|
|
223
240
|
}
|
|
224
241
|
let col = startCol;
|
|
225
242
|
for (const segment of segments) {
|
|
@@ -4,60 +4,81 @@
|
|
|
4
4
|
* The transcript renders an assistant turn as a small tree: the turn header at
|
|
5
5
|
* depth 0, each tool call it issues as a depth-1 branch, and each call's result
|
|
6
6
|
* as a depth-2 branch under that call. This module owns the *only* arithmetic
|
|
7
|
-
* that decides where those branch glyphs and
|
|
8
|
-
* type (event lines, tool-call rows, collapsed fragments) agrees on a
|
|
9
|
-
* column grid instead of each re-deriving it.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
7
|
+
* that decides where those branch glyphs, status markers and row text land, so
|
|
8
|
+
* every row type (event lines, tool-call rows, collapsed fragments) agrees on a
|
|
9
|
+
* single column grid instead of each re-deriving it.
|
|
10
|
+
*
|
|
11
|
+
* ## The grid
|
|
12
|
+
*
|
|
13
|
+
* One function generates every column, from LAYOUT.LEFT_MARGIN and the row's
|
|
14
|
+
* depth. With LEFT_MARGIN = 4 and TREE_STEP_COLS = 2 that is:
|
|
15
|
+
*
|
|
16
|
+
* depth 0 ` ● assistant …` marker col 3, text col 6
|
|
17
|
+
* depth 1 ` ✓ ├ read foo.ts` marker col 3, connector col 5, text col 8
|
|
18
|
+
* depth 2 ` │ └ ▸ 31 lines` connector col 7, text col 10
|
|
19
|
+
* depth 3 ` │ │ └ …` connector col 9, text col 12
|
|
20
|
+
*
|
|
21
|
+
* A level's connector sits where its parent's content began, and the row's
|
|
22
|
+
* first printable character sits two columns further right — the same
|
|
23
|
+
* marker→text relationship a depth-0 row has. So the step between levels is
|
|
24
|
+
* exactly TREE_STEP_COLS at every depth, for every row type.
|
|
25
|
+
*
|
|
26
|
+
* ## The status column IS the bullet column
|
|
27
|
+
*
|
|
28
|
+
* `treeStatusCol()` is defined as `treeBranchCol(0)` — literally the column the
|
|
29
|
+
* `●` of `● assistant` occupies. A tool row's ✓ / ✗ / ⊘ / ◌ therefore lands in
|
|
30
|
+
* the same column as the bullet of the turn that issued it, and reads as that
|
|
31
|
+
* bullet's column continuing down the turn rather than as a separate rail off
|
|
32
|
+
* to the left. Deriving it from treeBranchCol rather than restating a number is
|
|
33
|
+
* what keeps the two from drifting: move the bullet and the markers follow.
|
|
34
|
+
*
|
|
35
|
+
* The column is free at every depth ≥ 1 by construction — a branch row's own
|
|
36
|
+
* marker moves right with its indent, and depth 0 never contributes a `│`
|
|
37
|
+
* gutter (a top-level row has no ancestor to continue), so nothing else can
|
|
38
|
+
* claim it.
|
|
39
|
+
*
|
|
40
|
+
* ## Rails are continuous
|
|
41
|
+
*
|
|
42
|
+
* `drawTreeRails` paints EVERY line a row emits, not just its first: the
|
|
43
|
+
* connector on line 0, then `│` down the row's own column for as long as the
|
|
44
|
+
* row has a sibling below it, plus `│` in each open ancestor's gutter on every
|
|
45
|
+
* line. That is what closes the gaps a first-line-only connector leaves under
|
|
46
|
+
* multi-line rows (collapsed fragment boxes, wrapped text, expanded bodies).
|
|
47
|
+
*
|
|
48
|
+
* Rails never overwrite text: apart from the connector itself — which
|
|
49
|
+
* deliberately replaces the marker cell of the row it labels — a rail is drawn
|
|
50
|
+
* only into a cell that is blank and unstyled. A row whose own content reaches
|
|
51
|
+
* into a rail column keeps its content and loses the rail there.
|
|
52
|
+
*
|
|
53
|
+
* ## Narrow terminals
|
|
54
|
+
*
|
|
55
|
+
* Indenting unconditionally would eat the content budget and silently truncate
|
|
56
|
+
* the informative end of a row — the failure class the markdown-table column
|
|
57
|
+
* bug came from. treeIndentCols() therefore clamps the indent to whatever still
|
|
58
|
+
* leaves TREE_MIN_CONTENT_COLS of content, and drops to 0 (flush, no branch
|
|
59
|
+
* glyph) when even one level does not fit. Losing the visual nesting on a
|
|
60
|
+
* 30-column terminal is acceptable; losing characters is not.
|
|
34
61
|
*/
|
|
35
62
|
|
|
36
63
|
import { LAYOUT } from './layout.ts';
|
|
37
64
|
import type { Line } from '../types/grid.ts';
|
|
38
65
|
import { createStyledCell } from '../types/grid.ts';
|
|
66
|
+
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
39
67
|
|
|
40
68
|
/** Columns added per nesting level. Fixed — never content-dependent. */
|
|
41
69
|
const TREE_STEP_COLS = 2;
|
|
42
70
|
|
|
43
|
-
/**
|
|
44
|
-
* Width of the reserved status column that every row carries, at every depth.
|
|
45
|
-
*
|
|
46
|
-
* The column exists whether or not the row currently has a marker: a completed
|
|
47
|
-
* row shows ✓, a failed row its failure glyph, an in-flight or plain row a
|
|
48
|
-
* blank of the same width. Because the space is always reserved, a marker
|
|
49
|
-
* appearing or resolving changes only that cell's contents and can never shift
|
|
50
|
-
* the label beside it. Combined with fixed per-depth indentation this gives the
|
|
51
|
-
* whole tree one rule: columns are fixed, only their contents change.
|
|
52
|
-
*/
|
|
53
|
-
const TREE_STATUS_COLS = 2;
|
|
54
|
-
|
|
55
71
|
/**
|
|
56
72
|
* Content columns a branch row is guaranteed to keep. Below this the indent is
|
|
57
73
|
* reduced (and finally dropped) rather than squeezing the row's text.
|
|
58
74
|
*/
|
|
59
75
|
const TREE_MIN_CONTENT_COLS = 16;
|
|
60
76
|
|
|
77
|
+
/** Vertical rail drawn in an ancestor's gutter and down an open row's column. */
|
|
78
|
+
const TREE_RAIL = '│';
|
|
79
|
+
|
|
80
|
+
/** The connector a row draws when a sibling follows it below. */
|
|
81
|
+
const TREE_CONNECTOR_CONTINUES = '├';
|
|
61
82
|
|
|
62
83
|
/**
|
|
63
84
|
* Effective indent, in columns, for a branch at `depth` rendered at `width`.
|
|
@@ -69,57 +90,66 @@ const TREE_MIN_CONTENT_COLS = 16;
|
|
|
69
90
|
*/
|
|
70
91
|
export function treeIndentCols(depth: number, width: number): number {
|
|
71
92
|
if (depth <= 0) return 0;
|
|
72
|
-
|
|
73
|
-
const budget = width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - TREE_STATUS_COLS - TREE_MIN_CONTENT_COLS;
|
|
93
|
+
const budget = width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - TREE_MIN_CONTENT_COLS;
|
|
74
94
|
if (budget < TREE_STEP_COLS) return 0;
|
|
75
95
|
const affordableLevels = Math.floor(budget / TREE_STEP_COLS);
|
|
76
96
|
return Math.min(depth, affordableLevels) * TREE_STEP_COLS;
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* and colour-blind viewers.
|
|
100
|
+
* Column the row's leading glyph occupies for a given effective indent: the
|
|
101
|
+
* `●` of a depth-0 row, the `├`/`└` of a branch row, and the `│` an ancestor
|
|
102
|
+
* carries down through its descendants' rows.
|
|
84
103
|
*/
|
|
85
|
-
export function
|
|
86
|
-
|
|
87
|
-
glyph: string | null,
|
|
88
|
-
fg: string,
|
|
89
|
-
width: number,
|
|
90
|
-
): void {
|
|
91
|
-
if (!glyph || TREE_GUTTER_COL >= width) return;
|
|
92
|
-
line[TREE_GUTTER_COL] = createStyledCell(glyph, { fg, bold: true });
|
|
104
|
+
export function treeBranchCol(indentCols: number): number {
|
|
105
|
+
return LAYOUT.LEFT_MARGIN - 1 + indentCols;
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
/**
|
|
96
|
-
* Column
|
|
97
|
-
*
|
|
109
|
+
* Column at which a row's segment run begins. Segment runs carry one leading
|
|
110
|
+
* blank by convention (` assistant `, ` ▸ 31 lines `, the fragment's ` ▸ `
|
|
111
|
+
* prefix), so this is the gap column and treeTextCol is where glyphs land.
|
|
98
112
|
*/
|
|
99
|
-
export function
|
|
100
|
-
return
|
|
113
|
+
export function treeContentCol(indentCols: number): number {
|
|
114
|
+
return treeBranchCol(indentCols) + 2;
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* marker placed after the connector instead, status would sit at a different
|
|
109
|
-
* horizontal position on every row depending on nesting, so scanning for what
|
|
110
|
-
* completed means tracking a ragged edge — and that degrades precisely as
|
|
111
|
-
* trees get deeper. Here every marker in the transcript lines up in one
|
|
112
|
-
* column no matter how deep its row sits, for the cost of one fixed column.
|
|
113
|
-
*
|
|
114
|
-
* It is never clamped: narrow terminals clamp the INDENT (see treeIndentCols)
|
|
115
|
-
* and leave the gutter intact, because losing nesting depth is recoverable by
|
|
116
|
-
* widening the terminal while losing which rows succeeded is not.
|
|
118
|
+
* Column of a row's first PRINTABLE character. Every row type must land its
|
|
119
|
+
* text here — that is the invariant which makes each level step by exactly
|
|
120
|
+
* TREE_STEP_COLS regardless of whether the row came from an event line, a
|
|
121
|
+
* tool-call row or a collapsed fragment.
|
|
117
122
|
*/
|
|
118
|
-
|
|
123
|
+
export function treeTextCol(indentCols: number): number {
|
|
124
|
+
return treeContentCol(indentCols) + 1;
|
|
125
|
+
}
|
|
119
126
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Write a branch row's status marker into the shared bullet column.
|
|
129
|
+
*
|
|
130
|
+
* Success and failure differ by GLYPH, not only by colour, so the distinction
|
|
131
|
+
* survives a monochrome terminal and colour-blind viewers. A double-width glyph
|
|
132
|
+
* claims its trailing cell too, so the column math stays honest for emoji-class
|
|
133
|
+
* markers instead of leaving a stale half-character behind.
|
|
134
|
+
*/
|
|
135
|
+
export function writeTreeStatusMarker(
|
|
136
|
+
line: Line,
|
|
137
|
+
glyph: string | null,
|
|
138
|
+
fg: string,
|
|
139
|
+
width: number,
|
|
140
|
+
): void {
|
|
141
|
+
if (!glyph) return;
|
|
142
|
+
// THE column: treeBranchCol(0) is where a depth-0 row draws its `●`, so a
|
|
143
|
+
// branch row's marker lands in the bullet column of the turn it belongs to.
|
|
144
|
+
// Derived, never restated, so the two cannot drift apart.
|
|
145
|
+
const col = treeBranchCol(0);
|
|
146
|
+
if (col < 0 || col >= width) return;
|
|
147
|
+
const glyphWidth = getDisplayWidth(glyph);
|
|
148
|
+
if (glyphWidth <= 0) return;
|
|
149
|
+
line[col] = createStyledCell(glyph, { fg, bold: true });
|
|
150
|
+
if (glyphWidth > 1 && col + 1 < width) {
|
|
151
|
+
line[col + 1] = createStyledCell('', { fg, bold: true });
|
|
152
|
+
}
|
|
123
153
|
}
|
|
124
154
|
|
|
125
155
|
/**
|
|
@@ -145,35 +175,54 @@ function treeGutterCols(
|
|
|
145
175
|
return cols;
|
|
146
176
|
}
|
|
147
177
|
|
|
178
|
+
/** True when a rail may claim this cell without destroying rendered content. */
|
|
179
|
+
function railCellIsFree(line: Line, col: number): boolean {
|
|
180
|
+
const cell = line[col];
|
|
181
|
+
if (!cell) return false;
|
|
182
|
+
return cell.char === ' ' && !cell.bg;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeRail(line: Line, col: number, glyph: string, fg: string, width: number, force: boolean): void {
|
|
186
|
+
if (col < 0 || col >= width) return;
|
|
187
|
+
if (!force && !railCellIsFree(line, col)) return;
|
|
188
|
+
line[col] = createStyledCell(glyph, { fg, dim: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
148
191
|
/**
|
|
149
|
-
* Draw a
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
192
|
+
* Draw a row's connector and every rail that must pass through it, across ALL
|
|
193
|
+
* the lines the row emitted.
|
|
194
|
+
*
|
|
195
|
+
* Line 0 gets the connector in the row's own column (deliberately replacing
|
|
196
|
+
* whatever marker the row drew there — the connector IS that row's marker).
|
|
197
|
+
* Subsequent lines get `│` in that column while the row still has a sibling
|
|
198
|
+
* below it, so the rail runs unbroken from one `├` to the next. Every line
|
|
199
|
+
* additionally gets `│` in each open ancestor's gutter.
|
|
200
|
+
*
|
|
201
|
+
* Connectors and gutters are recomputed from live structure on every rebuild
|
|
202
|
+
* (see conversation-turn-structure.ts), never cached, so a row that stops being
|
|
203
|
+
* last flips `└`→`├` here with nothing stale to invalidate.
|
|
158
204
|
*/
|
|
159
|
-
export function
|
|
160
|
-
|
|
205
|
+
export function drawTreeRails(
|
|
206
|
+
lines: readonly Line[],
|
|
161
207
|
depth: number,
|
|
162
208
|
connector: string | undefined,
|
|
163
209
|
openAncestorDepths: readonly number[],
|
|
164
210
|
width: number,
|
|
165
211
|
fg: string,
|
|
166
212
|
): void {
|
|
167
|
-
if (depth <= 0 || !connector) return;
|
|
213
|
+
if (depth <= 0 || !connector || lines.length === 0) return;
|
|
168
214
|
const indent = treeIndentCols(depth, width);
|
|
169
215
|
if (indent <= 0) return;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
216
|
+
const ownCol = treeBranchCol(indent);
|
|
217
|
+
const gutterCols = treeGutterCols(openAncestorDepths, depth, width);
|
|
218
|
+
const continuesBelow = connector === TREE_CONNECTOR_CONTINUES;
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < lines.length; i++) {
|
|
221
|
+
const line = lines[i]!;
|
|
222
|
+
for (const gutterCol of gutterCols) {
|
|
223
|
+
writeRail(line, gutterCol, TREE_RAIL, fg, width, false);
|
|
173
224
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (col >= 0 && col < width) {
|
|
177
|
-
line[col] = createStyledCell(connector, { fg, dim: true });
|
|
225
|
+
if (i === 0) writeRail(line, ownCol, connector, fg, width, true);
|
|
226
|
+
else if (continuesBelow) writeRail(line, ownCol, TREE_RAIL, fg, width, false);
|
|
178
227
|
}
|
|
179
228
|
}
|
|
@@ -6,7 +6,7 @@ import { stripDangerousAnsi } from './ansi-sanitize.ts';
|
|
|
6
6
|
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
7
7
|
import { GLYPHS } from './ui-primitives.ts';
|
|
8
8
|
import { activeUiTones } from './theme.ts';
|
|
9
|
-
import {
|
|
9
|
+
import { treeContentCol, treeTextCol, writeTreeStatusMarker } from './conversation-tree.ts';
|
|
10
10
|
|
|
11
11
|
const TOOL_NAME_MIN_WIDTH = 8;
|
|
12
12
|
const TOOL_NAME_MAX_WIDTH = 20;
|
|
@@ -188,10 +188,11 @@ export function renderToolCallBlock(
|
|
|
188
188
|
const line = createEmptyLine(width);
|
|
189
189
|
const t = activeUiTones();
|
|
190
190
|
const indent = Math.max(0, tree?.indentCols ?? 0);
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
191
|
+
const inTree = indent > 0;
|
|
192
|
+
// In tree mode the row's columns come from the shared grid: the branch glyph
|
|
193
|
+
// where the parent's content began, the status marker in the bullet column,
|
|
194
|
+
// and the row's text at this depth's text column.
|
|
195
|
+
const margin = inTree ? treeContentCol(indent) : LAYOUT.LEFT_MARGIN;
|
|
195
196
|
const rightMargin = LAYOUT.RIGHT_MARGIN;
|
|
196
197
|
const contentEnd = width - rightMargin;
|
|
197
198
|
|
|
@@ -226,18 +227,18 @@ export function renderToolCallBlock(
|
|
|
226
227
|
const rightStart = rightText
|
|
227
228
|
? Math.max(margin + 2, contentEnd - rightWidth)
|
|
228
229
|
: contentEnd;
|
|
229
|
-
const leftStart = margin;
|
|
230
|
+
const leftStart = inTree ? treeTextCol(indent) : margin;
|
|
230
231
|
const leftEndExclusive = rightText
|
|
231
232
|
? Math.max(leftStart, rightStart - 1)
|
|
232
233
|
: contentEnd;
|
|
233
234
|
let col: number = leftStart;
|
|
234
235
|
|
|
235
|
-
// In tree mode the status glyph goes in the
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
if (
|
|
240
|
-
|
|
236
|
+
// In tree mode the status glyph goes in the bullet column — the same column
|
|
237
|
+
// as the `●` of the `● assistant` header this row hangs under — so a turn's
|
|
238
|
+
// markers read as that bullet's column continuing down the turn. The row's
|
|
239
|
+
// own text then starts at its depth's text column with no inline icon slot.
|
|
240
|
+
if (inTree) {
|
|
241
|
+
writeTreeStatusMarker(line, icon, iconColor, width);
|
|
241
242
|
} else {
|
|
242
243
|
if (col < leftEndExclusive) {
|
|
243
244
|
line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' || status === 'cancelled' });
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -483,7 +483,8 @@ export async function bootstrapRuntime(
|
|
|
483
483
|
|
|
484
484
|
// Embedded DaemonServer/HttpListener factories (incl. the facade auto-update
|
|
485
485
|
// suppression) — see embedded-service-factories.ts.
|
|
486
|
-
const createExternalServiceFactories =
|
|
486
|
+
const createExternalServiceFactories = (token: string): ExternalServiceFactories =>
|
|
487
|
+
createEmbeddedServiceFactories(token, services.clusterCoordinator, services.clusterGroup.verbs);
|
|
487
488
|
|
|
488
489
|
let externalServices: ExternalServicesHandle = {
|
|
489
490
|
daemonServer: null, httpListener: null,
|
|
@@ -595,6 +596,10 @@ export async function bootstrapRuntime(
|
|
|
595
596
|
createExternalServiceFactories(companionTokenRecord.token),
|
|
596
597
|
);
|
|
597
598
|
externalServices = await externalServicesPromise;
|
|
599
|
+
// Join the coordination group even with no embedded daemon: the inbox
|
|
600
|
+
// poller is gated on leadership and nothing else would start it when
|
|
601
|
+
// daemon.enabled is false. Idempotent with the DaemonServer's own call.
|
|
602
|
+
await services.startCluster();
|
|
598
603
|
controlPlaneRecentEventsRef.value = (limit) => externalServices.listRecentControlPlaneEvents(limit);
|
|
599
604
|
syncSessionSpineToHostStatus(externalServices.daemonStatus, companionTokenRecord.token);
|
|
600
605
|
await renderDaemonAttachNotices(companionTokenRecord.token);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cluster-composition.ts — this daemon's seat in the LAN leader election.
|
|
3
|
+
*
|
|
4
|
+
* When the same goodvibes install runs more than once on one network — a
|
|
5
|
+
* laptop and a desktop, or two processes on one machine — every copy
|
|
6
|
+
* independently reads the shared inbox, so one message is picked up twice and
|
|
7
|
+
* answered twice. The SDK's cluster coordinator elects exactly one node to be
|
|
8
|
+
* responsible for inbound consumption; everything else stays warm and silent.
|
|
9
|
+
*
|
|
10
|
+
* This file exists because the goodvibes-tui daemon does NOT get that for free
|
|
11
|
+
* from the SDK facade. The facade gates the consumers IT owns (Telegram
|
|
12
|
+
* ingress, the ntfy/Slack/Discord provider runtime), but this repository
|
|
13
|
+
* composes an inbound consumer of its own — the Slack/Discord/email inbox
|
|
14
|
+
* poller in daemon/handlers/inbox — and the facade knows nothing about it.
|
|
15
|
+
* A fix that only landed in the facade would leave the poller double-reading
|
|
16
|
+
* on exactly the machine this product runs on.
|
|
17
|
+
*
|
|
18
|
+
* So this composition builds the ONE coordinator for the process, registers
|
|
19
|
+
* the inbox poller with it, and hands the same instance to the DaemonServer
|
|
20
|
+
* (via `DaemonConfig.clusterCoordinator`) so the SDK's consumers ride the same
|
|
21
|
+
* leadership rather than holding a second, competing election.
|
|
22
|
+
*
|
|
23
|
+
* What leadership does NOT gate, here or anywhere: outbound delivery,
|
|
24
|
+
* sessions, the control plane, the HTTP listener, the UI. A standby node is a
|
|
25
|
+
* complete goodvibes daemon that simply is not the one reading the inbox.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
ClusterCoordinator,
|
|
29
|
+
inboxSurface,
|
|
30
|
+
readClusterSettings,
|
|
31
|
+
type ClusterConsumerGate,
|
|
32
|
+
type ClusterTransport,
|
|
33
|
+
} from '@pellux/goodvibes-sdk/platform/cluster';
|
|
34
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
35
|
+
import type { ShellPathService } from '@/runtime/index.ts';
|
|
36
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
37
|
+
import { GOODVIBES_TUI_SURFACE_ROOT } from '../config/surface.ts';
|
|
38
|
+
import { VERSION } from '../version.ts';
|
|
39
|
+
|
|
40
|
+
/** What an inbound poller must expose to be placed under leadership. */
|
|
41
|
+
export interface GatedPollerControl {
|
|
42
|
+
/** Begin polling. Must not resolve until polling has actually begun. */
|
|
43
|
+
start(): Promise<void>;
|
|
44
|
+
/** Stop polling. Must not resolve until no further poll can run. */
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the process's single coordinator.
|
|
50
|
+
*
|
|
51
|
+
* Constructing it is inert: no socket is opened and no state is written until
|
|
52
|
+
* `start()` runs, so composing a runtime in a test never joins a network.
|
|
53
|
+
*
|
|
54
|
+
* The version is THIS BINARY's version, not the SDK package's — the same
|
|
55
|
+
* distinction daemon/cli.ts already makes for the self-update artifact. It is
|
|
56
|
+
* the first ranking tier, so getting it wrong would let a stale build hold the
|
|
57
|
+
* role through an update.
|
|
58
|
+
*/
|
|
59
|
+
export function createClusterComposition(options: {
|
|
60
|
+
/**
|
|
61
|
+
* Narrowed to what is actually read, which is exactly what
|
|
62
|
+
* `readClusterSettings` asks for. The daemon passes a whole ConfigManager;
|
|
63
|
+
* the handler context carries only this slice, and a test standing in a
|
|
64
|
+
* minimal config object should not have to fabricate the full fifty-odd
|
|
65
|
+
* members to compose a coordinator — a cast there would hide a real shape
|
|
66
|
+
* mismatch rather than document one.
|
|
67
|
+
*/
|
|
68
|
+
readonly configManager: Pick<ConfigManager, 'getCategory'>;
|
|
69
|
+
readonly shellPaths: ShellPathService;
|
|
70
|
+
/**
|
|
71
|
+
* The datagram transport to coordinate over.
|
|
72
|
+
*
|
|
73
|
+
* Supplied by the group layer (cluster-group-composition.ts), which owns the
|
|
74
|
+
* socket and wraps every election datagram in the group envelope, signed with
|
|
75
|
+
* the current group key. That is what stops a daemon belonging to somebody
|
|
76
|
+
* else on the same network from taking part in this election at all.
|
|
77
|
+
*
|
|
78
|
+
* Absent — the plain default — means the coordinator opens its own socket and
|
|
79
|
+
* coordinates with anything that answers on the configured port.
|
|
80
|
+
*/
|
|
81
|
+
readonly transport?: ClusterTransport | undefined;
|
|
82
|
+
}): ClusterCoordinator {
|
|
83
|
+
return new ClusterCoordinator({
|
|
84
|
+
settings: readClusterSettings(options.configManager),
|
|
85
|
+
version: VERSION,
|
|
86
|
+
// Surface-scoped, alongside this surface's other durable state: the node
|
|
87
|
+
// identity is per-surface and must not leak across surface roots.
|
|
88
|
+
stateDirectory: options.shellPaths.resolveProjectPath(GOODVIBES_TUI_SURFACE_ROOT, 'cluster'),
|
|
89
|
+
logger,
|
|
90
|
+
...(options.transport ? { transport: options.transport } : {}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* ONE inbox provider as a leadership gate.
|
|
96
|
+
*
|
|
97
|
+
* One gate per account, not one for the poller. Each inbox account is its own
|
|
98
|
+
* surface in the election, so this laptop can hold the work Slack account
|
|
99
|
+
* while the desktop holds the mailbox — and losing one machine moves only the
|
|
100
|
+
* accounts it was reading. A single gate covering the whole poller could not
|
|
101
|
+
* express that: it would hand every account over together, and a node with a
|
|
102
|
+
* credential for only one of them could never take part at all.
|
|
103
|
+
*
|
|
104
|
+
* The provider id is the surface's local discriminator and is hashed before it
|
|
105
|
+
* reaches the network, so what travels is a digest and not an account name.
|
|
106
|
+
*
|
|
107
|
+
* No replay cursor is threaded in, and that is deliberate rather than an
|
|
108
|
+
* omission: the inbox keeps a persisted per-provider cursor of its own
|
|
109
|
+
* (InboxCursorStore), so a node taking an account over resumes from where the
|
|
110
|
+
* previous one committed. ntfy needs an explicit `since=` because it has no
|
|
111
|
+
* server-side per-subscriber cursor; this poller does not.
|
|
112
|
+
*/
|
|
113
|
+
export function inboxPollerGate(providerId: string, control: GatedPollerControl): ClusterConsumerGate {
|
|
114
|
+
return {
|
|
115
|
+
id: `inbox-poller:${providerId}`,
|
|
116
|
+
surface: inboxSurface(providerId),
|
|
117
|
+
start: async () => {
|
|
118
|
+
await control.start();
|
|
119
|
+
},
|
|
120
|
+
stop: async () => {
|
|
121
|
+
await control.stop();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|