@esso0428/pi-subagents 0.17.1 → 0.17.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 +19 -0
- package/README.md +5 -4
- package/dist/agent-history.d.ts +6 -0
- package/dist/agent-history.d.ts.map +1 -1
- package/dist/agent-history.js +38 -0
- package/dist/agent-history.js.map +1 -1
- package/dist/agent-manager.d.ts +23 -6
- package/dist/agent-manager.d.ts.map +1 -1
- package/dist/agent-manager.js +165 -27
- package/dist/agent-manager.js.map +1 -1
- package/dist/agent-recovery.d.ts +37 -0
- package/dist/agent-recovery.d.ts.map +1 -0
- package/dist/agent-recovery.js +171 -0
- package/dist/agent-recovery.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -25
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-widget.d.ts +4 -0
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +100 -23
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +1 -1
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +6 -0
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +79 -7
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-history.ts +38 -0
- package/src/agent-manager.ts +170 -28
- package/src/agent-recovery.ts +185 -0
- package/src/index.ts +103 -23
- package/src/ui/agent-widget.ts +102 -23
- package/src/ui/conversation-timeline.ts +4 -1
- package/src/ui/conversation-viewer.ts +97 -7
package/src/index.ts
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Container, Key, matchesKey, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "@sinclair/typebox";
|
|
18
|
-
import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory } from "./agent-history.js";
|
|
18
|
+
import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, readAgentHistoryResult } from "./agent-history.js";
|
|
19
19
|
import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
|
|
20
20
|
import { AgentManager } from "./agent-manager.js";
|
|
21
21
|
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
@@ -171,8 +171,8 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "ma
|
|
|
171
171
|
* surfaces (or "" if the run produced nothing). `record.result` is bounded to
|
|
172
172
|
* the run's own turns, so this is never a stale earlier answer (#144).
|
|
173
173
|
*/
|
|
174
|
-
function partialOutputSuffix(record: AgentRecord): string {
|
|
175
|
-
const partial = record.result?.trim();
|
|
174
|
+
function partialOutputSuffix(record: AgentRecord, fallback?: string): string {
|
|
175
|
+
const partial = record.result?.trim() || fallback?.trim();
|
|
176
176
|
return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -450,7 +450,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
450
450
|
// Persist final record for cross-extension history reconstruction
|
|
451
451
|
pi.appendEntry("subagents:record", {
|
|
452
452
|
id: record.id, type: record.type, description: record.description,
|
|
453
|
-
status: record.status,
|
|
453
|
+
status: record.status,
|
|
454
|
+
// Durable transcripts are the source of truth for full output. Avoid
|
|
455
|
+
// copying a potentially large result into the parent session branch;
|
|
456
|
+
// get_subagent_result reloads it on demand after cleanup/restart.
|
|
457
|
+
result: record.transcriptPath ? undefined : record.result,
|
|
458
|
+
error: record.error,
|
|
454
459
|
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
455
460
|
toolUses: record.toolUses,
|
|
456
461
|
lifetimeUsage: record.lifetimeUsage,
|
|
@@ -555,16 +560,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
555
560
|
}
|
|
556
561
|
}
|
|
557
562
|
|
|
563
|
+
type AgentMenuSelection = { id?: string; index: number };
|
|
564
|
+
let runningAgentSelection: AgentMenuSelection = { index: 0 };
|
|
565
|
+
let historyAgentSelection: AgentMenuSelection = { index: 0 };
|
|
566
|
+
|
|
567
|
+
function resetAgentMenuSelections() {
|
|
568
|
+
runningAgentSelection = { index: 0 };
|
|
569
|
+
historyAgentSelection = { index: 0 };
|
|
570
|
+
}
|
|
571
|
+
|
|
558
572
|
// Capture ctx from session_start for RPC spawn handler + start the scheduler.
|
|
559
573
|
// This also wires the RPC handlers and broadcasts readiness — on the first
|
|
560
574
|
// bound session_start, so a filtered-out activation never advertises (#142).
|
|
561
575
|
pi.on("session_start", async (_event, ctx) => {
|
|
576
|
+
resetAgentMenuSelections();
|
|
562
577
|
currentCtx = ctx;
|
|
563
578
|
manager.clearCompleted(true);
|
|
564
579
|
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
565
580
|
manager.restoreCompleted(branch
|
|
566
581
|
.filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
|
|
567
582
|
.map((entry: any) => entry.data));
|
|
583
|
+
// Checkpoint files cover agents whose parent session never got a terminal
|
|
584
|
+
// branch entry (shutdown, session switch, or a process restart).
|
|
585
|
+
manager.restoreRecovered(ctx.cwd);
|
|
568
586
|
// Attach the panel during TUI startup, after restored records are present,
|
|
569
587
|
// so terminal agents from the session branch are immediately visible.
|
|
570
588
|
if (ctx.mode === "tui") {
|
|
@@ -589,6 +607,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
589
607
|
});
|
|
590
608
|
|
|
591
609
|
pi.on("session_before_switch", () => {
|
|
610
|
+
resetAgentMenuSelections();
|
|
611
|
+
// A switch is catchable. Stop and checkpoint live/queued agents before the
|
|
612
|
+
// old session context is discarded, then retain their unread history.
|
|
613
|
+
manager.abortAll();
|
|
592
614
|
manager.clearCompleted(true);
|
|
593
615
|
scheduler.stop();
|
|
594
616
|
});
|
|
@@ -596,6 +618,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
618
|
// On shutdown, abort all agents immediately and clean up.
|
|
597
619
|
// If the session is going down, there's nothing left to consume agent results.
|
|
598
620
|
pi.on("session_shutdown", async () => {
|
|
621
|
+
resetAgentMenuSelections();
|
|
599
622
|
rpcHandle?.unsubSpawn();
|
|
600
623
|
rpcHandle?.unsubStop();
|
|
601
624
|
rpcHandle?.unsubPing();
|
|
@@ -1163,6 +1186,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1163
1186
|
rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
|
|
1164
1187
|
rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
|
|
1165
1188
|
writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
|
|
1189
|
+
manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
|
|
1166
1190
|
} catch (err) {
|
|
1167
1191
|
rec.historyFile = undefined;
|
|
1168
1192
|
rec.transcriptPath = undefined;
|
|
@@ -1274,9 +1298,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1274
1298
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
1275
1299
|
|
|
1276
1300
|
// Wrap onSessionCreated to wire output file streaming.
|
|
1277
|
-
// The callback
|
|
1278
|
-
//
|
|
1301
|
+
// The callback reads the transcript paths installed synchronously by
|
|
1302
|
+
// onSpawned before the agent can queue or start.
|
|
1279
1303
|
let id: string;
|
|
1304
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1280
1305
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
1281
1306
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
1282
1307
|
origBgOnSession(session);
|
|
@@ -1297,20 +1322,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1297
1322
|
isBackground: true,
|
|
1298
1323
|
isolation,
|
|
1299
1324
|
invocation: agentInvocation,
|
|
1325
|
+
onSpawned: (spawnedId) => {
|
|
1326
|
+
attachTranscript(manager.getRecord(spawnedId), spawnedId);
|
|
1327
|
+
},
|
|
1300
1328
|
...bgCallbacks,
|
|
1301
1329
|
});
|
|
1302
1330
|
} catch (err) {
|
|
1303
1331
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
1304
1332
|
}
|
|
1305
1333
|
|
|
1306
|
-
// Set
|
|
1307
|
-
//
|
|
1308
|
-
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1334
|
+
// Set join metadata after spawn. Transcript metadata was installed by
|
|
1335
|
+
// the manager's synchronous onSpawned callback before this point.
|
|
1309
1336
|
const record = manager.getRecord(id);
|
|
1310
1337
|
if (record && joinMode) {
|
|
1311
1338
|
record.joinMode = joinMode;
|
|
1312
1339
|
record.toolCallId = toolCallId;
|
|
1313
|
-
attachTranscript(record, id);
|
|
1314
1340
|
}
|
|
1315
1341
|
|
|
1316
1342
|
if (joinMode == null || joinMode === 'async') {
|
|
@@ -1512,6 +1538,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1512
1538
|
if (record.promise) await abortable(record.promise, signal);
|
|
1513
1539
|
}
|
|
1514
1540
|
|
|
1541
|
+
const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
|
|
1542
|
+
? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
|
|
1543
|
+
: undefined;
|
|
1515
1544
|
const displayName = getDisplayName(record.type);
|
|
1516
1545
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
1517
1546
|
const tokens = formatLifetimeTokens(record);
|
|
@@ -1530,9 +1559,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1530
1559
|
if (record.status === "running") {
|
|
1531
1560
|
output += "Agent is still running. Use wait: true or check back later.";
|
|
1532
1561
|
} else if (record.status === "error") {
|
|
1533
|
-
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
1562
|
+
output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
|
|
1534
1563
|
} else {
|
|
1535
|
-
output += record.result?.trim() || "No output.";
|
|
1564
|
+
output += durableResult || record.result?.trim() || "No output.";
|
|
1536
1565
|
}
|
|
1537
1566
|
|
|
1538
1567
|
// Mark result as consumed — suppresses the completion notification
|
|
@@ -1789,6 +1818,59 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1789
1818
|
});
|
|
1790
1819
|
}
|
|
1791
1820
|
|
|
1821
|
+
async function selectAgentFromReadOnlyList(
|
|
1822
|
+
ctx: ExtensionCommandContext,
|
|
1823
|
+
title: string,
|
|
1824
|
+
pairs: Array<{ record: AgentRecord; label: string }>,
|
|
1825
|
+
selection: AgentMenuSelection,
|
|
1826
|
+
): Promise<AgentRecord | undefined> {
|
|
1827
|
+
const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
|
|
1828
|
+
const rememberedIndex = selection.id
|
|
1829
|
+
? pairs.findIndex(({ record }) => record.id === selection.id)
|
|
1830
|
+
: -1;
|
|
1831
|
+
const initialIndex = rememberedIndex >= 0
|
|
1832
|
+
? rememberedIndex
|
|
1833
|
+
: Math.max(0, Math.min(selection.index, pairs.length - 1));
|
|
1834
|
+
|
|
1835
|
+
const remember = (id: string) => {
|
|
1836
|
+
const index = pairs.findIndex(({ record }) => record.id === id);
|
|
1837
|
+
if (index >= 0) {
|
|
1838
|
+
selection.id = id;
|
|
1839
|
+
selection.index = index;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
const choice = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
|
|
1844
|
+
const list = new SelectList(
|
|
1845
|
+
options,
|
|
1846
|
+
Math.min(options.length, 10),
|
|
1847
|
+
getSelectListTheme(),
|
|
1848
|
+
);
|
|
1849
|
+
list.setSelectedIndex(initialIndex);
|
|
1850
|
+
const initialItem = options[initialIndex];
|
|
1851
|
+
if (initialItem) remember(initialItem.value);
|
|
1852
|
+
list.onSelectionChange = item => remember(item.value);
|
|
1853
|
+
list.onSelect = item => {
|
|
1854
|
+
remember(item.value);
|
|
1855
|
+
done(item.value);
|
|
1856
|
+
};
|
|
1857
|
+
list.onCancel = () => done(undefined);
|
|
1858
|
+
|
|
1859
|
+
const container = new Container();
|
|
1860
|
+
container.addChild(new Text(title, 0, 0));
|
|
1861
|
+
container.addChild(new Spacer(1));
|
|
1862
|
+
container.addChild(list);
|
|
1863
|
+
return {
|
|
1864
|
+
render: (w: number) => container.render(w),
|
|
1865
|
+
invalidate: () => container.invalidate(),
|
|
1866
|
+
handleInput: (data: string) => list.handleInput(data),
|
|
1867
|
+
};
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
if (!choice) return undefined;
|
|
1871
|
+
return pairs.find(({ record }) => record.id === choice)?.record;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1792
1874
|
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
1793
1875
|
const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
|
|
1794
1876
|
if (agents.length === 0) {
|
|
@@ -1801,15 +1883,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1801
1883
|
const dur = formatDuration(record.startedAt, record.completedAt);
|
|
1802
1884
|
return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
|
|
1803
1885
|
});
|
|
1804
|
-
|
|
1886
|
+
makeUniqueAgentOptionLabels(pairs);
|
|
1805
1887
|
|
|
1806
|
-
const
|
|
1807
|
-
if (!choice) return;
|
|
1808
|
-
const record = pairs.find((pair) => pair.label === choice)?.record;
|
|
1888
|
+
const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
|
|
1809
1889
|
if (!record) return;
|
|
1810
1890
|
|
|
1811
1891
|
await viewAgentConversation(ctx, record, "live");
|
|
1812
|
-
// Back-navigation: re-show the list
|
|
1892
|
+
// Back-navigation: re-show the list at the previously selected agent.
|
|
1813
1893
|
await showRunningAgents(ctx);
|
|
1814
1894
|
}
|
|
1815
1895
|
|
|
@@ -1821,13 +1901,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1821
1901
|
}
|
|
1822
1902
|
|
|
1823
1903
|
const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
|
|
1824
|
-
|
|
1825
|
-
const
|
|
1826
|
-
if (!choice) return;
|
|
1827
|
-
const record = pairs.find((pair) => pair.label === choice)?.record;
|
|
1904
|
+
makeUniqueAgentOptionLabels(pairs);
|
|
1905
|
+
const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
|
|
1828
1906
|
if (!record) return;
|
|
1829
1907
|
|
|
1830
1908
|
await viewAgentConversation(ctx, record, "history");
|
|
1909
|
+
// Back-navigation: re-show the list at the previously selected agent.
|
|
1910
|
+
await showAgentHistory(ctx);
|
|
1831
1911
|
}
|
|
1832
1912
|
|
|
1833
1913
|
async function viewAgentConversation(
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Uses the callback form of setWidget for themed rendering.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import type { AgentManager } from "../agent-manager.js";
|
|
10
10
|
import { getConfig } from "../agent-types.js";
|
|
11
11
|
import type { AgentInvocation, AgentRecord, SubagentType, WidgetMode } from "../types.js";
|
|
@@ -249,6 +249,8 @@ export class AgentWidget {
|
|
|
249
249
|
private navigationActive = false;
|
|
250
250
|
/** Stable identity of the selected row, so roster changes do not jump selection. */
|
|
251
251
|
private selectedAgentId: string | undefined;
|
|
252
|
+
/** Last logical roster index of the selected row, used when it disappears. */
|
|
253
|
+
private selectedRosterIndex = 0;
|
|
252
254
|
/** First logical row currently represented by the bounded viewport. */
|
|
253
255
|
private viewportStart = 0;
|
|
254
256
|
|
|
@@ -307,6 +309,7 @@ export class AgentWidget {
|
|
|
307
309
|
this.lastRenderKey = undefined;
|
|
308
310
|
this.navigationActive = false;
|
|
309
311
|
this.selectedAgentId = undefined;
|
|
312
|
+
this.selectedRosterIndex = 0;
|
|
310
313
|
this.viewportStart = 0;
|
|
311
314
|
// Print/RPC tests and lightweight embedders may provide only the widget
|
|
312
315
|
// surface; real interactive contexts always implement this hook.
|
|
@@ -382,9 +385,29 @@ export class AgentWidget {
|
|
|
382
385
|
}
|
|
383
386
|
|
|
384
387
|
private deactivate(): void {
|
|
388
|
+
// Keep the selected row and viewport so re-entering navigation can resume
|
|
389
|
+
// where the user left off. Lifecycle resets (context, dispose, empty
|
|
390
|
+
// roster) clear this state explicitly instead of treating every exit as a
|
|
391
|
+
// reset.
|
|
385
392
|
this.navigationActive = false;
|
|
386
|
-
this.
|
|
387
|
-
|
|
393
|
+
this.update();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Resume navigation from the retained row, or select the first row. */
|
|
397
|
+
private activate(records: readonly AgentRecord[]): void {
|
|
398
|
+
this.navigationActive = true;
|
|
399
|
+
const selectedIndex = this.selectedIndexOf(records);
|
|
400
|
+
if (selectedIndex >= 0) {
|
|
401
|
+
this.selectedRosterIndex = selectedIndex;
|
|
402
|
+
} else {
|
|
403
|
+
// The previously selected row may have been cleaned up while navigation
|
|
404
|
+
// was inactive. Resume at its old logical position, clamped to the new
|
|
405
|
+
// roster, rather than jumping back to the first row.
|
|
406
|
+
const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, records.length - 1));
|
|
407
|
+
this.selectedAgentId = records[fallbackIndex].id;
|
|
408
|
+
this.selectedRosterIndex = fallbackIndex;
|
|
409
|
+
this.viewportStart = Math.min(this.viewportStart, Math.max(0, records.length - 1));
|
|
410
|
+
}
|
|
388
411
|
this.update();
|
|
389
412
|
}
|
|
390
413
|
|
|
@@ -396,10 +419,7 @@ export class AgentWidget {
|
|
|
396
419
|
|
|
397
420
|
if (!this.navigationActive) {
|
|
398
421
|
if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
|
|
399
|
-
this.
|
|
400
|
-
this.selectedAgentId = records[0].id;
|
|
401
|
-
this.viewportStart = 0;
|
|
402
|
-
this.update();
|
|
422
|
+
this.activate(records);
|
|
403
423
|
return true;
|
|
404
424
|
}
|
|
405
425
|
|
|
@@ -410,6 +430,7 @@ export class AgentWidget {
|
|
|
410
430
|
}
|
|
411
431
|
const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
|
|
412
432
|
this.selectedAgentId = records[nextIndex].id;
|
|
433
|
+
this.selectedRosterIndex = nextIndex;
|
|
413
434
|
this.update();
|
|
414
435
|
return true;
|
|
415
436
|
}
|
|
@@ -431,13 +452,11 @@ export class AgentWidget {
|
|
|
431
452
|
}
|
|
432
453
|
|
|
433
454
|
if (!this.navigationActive) {
|
|
434
|
-
|
|
455
|
+
const records = this.roster();
|
|
456
|
+
if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
|
|
435
457
|
return undefined;
|
|
436
458
|
}
|
|
437
|
-
this.
|
|
438
|
-
this.selectedAgentId = this.roster()[0]?.id;
|
|
439
|
-
this.viewportStart = 0;
|
|
440
|
-
this.update();
|
|
459
|
+
this.activate(records);
|
|
441
460
|
return { consume: true };
|
|
442
461
|
}
|
|
443
462
|
|
|
@@ -518,7 +537,7 @@ export class AgentWidget {
|
|
|
518
537
|
const w = tui.terminal.columns;
|
|
519
538
|
const maxLines = getWidgetLineBudget(tui.terminal.rows);
|
|
520
539
|
if (maxLines === 0) return [];
|
|
521
|
-
const truncate = (line: string) => truncateToWidth(line,
|
|
540
|
+
const truncate = (line: string, width = w) => truncateToWidth(line, Math.max(0, width));
|
|
522
541
|
const headingColor = hasActive ? "accent" : "dim";
|
|
523
542
|
const headingIcon = hasActive ? "●" : "○";
|
|
524
543
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
@@ -604,12 +623,12 @@ export class AgentWidget {
|
|
|
604
623
|
// Reserve one line for a directional overflow summary. The viewport is
|
|
605
624
|
// a contiguous slice in roster order, so the same slice is navigable and
|
|
606
625
|
// renderable even when the selected row is currently hidden.
|
|
607
|
-
const
|
|
626
|
+
const baseContentBudget = Math.max(0, maxBody - 1);
|
|
608
627
|
const heightAt = (index: number) => rows[index]?.lines.length ?? 0;
|
|
609
|
-
const endFor = (start: number): number => {
|
|
628
|
+
const endFor = (start: number, budget: number): number => {
|
|
610
629
|
let used = 0;
|
|
611
630
|
let end = start;
|
|
612
|
-
while (end < rows.length && used + heightAt(end) <=
|
|
631
|
+
while (end < rows.length && used + heightAt(end) <= budget) {
|
|
613
632
|
used += heightAt(end++);
|
|
614
633
|
}
|
|
615
634
|
return end;
|
|
@@ -621,21 +640,33 @@ export class AgentWidget {
|
|
|
621
640
|
: -1;
|
|
622
641
|
if (selectedIndex >= 0) {
|
|
623
642
|
if (selectedIndex < start) start = selectedIndex;
|
|
624
|
-
if (selectedIndex >= endFor(start)) start = selectedIndex;
|
|
643
|
+
if (selectedIndex >= endFor(start, baseContentBudget)) start = selectedIndex;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Keep both directional affordances in the viewport chrome: the top
|
|
647
|
+
// count sits below the heading and the bottom count sits below the
|
|
648
|
+
// bounded roster. Each consumes one row when that direction is hidden.
|
|
649
|
+
let showTopMore = start > 0;
|
|
650
|
+
let contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
|
|
651
|
+
if (selectedIndex >= 0 && selectedIndex >= endFor(start, contentBudget)) {
|
|
652
|
+
start = selectedIndex;
|
|
653
|
+
showTopMore = start > 0;
|
|
654
|
+
contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
|
|
625
655
|
}
|
|
626
656
|
this.viewportStart = start;
|
|
627
657
|
|
|
628
658
|
let used = 0;
|
|
629
659
|
let end = start;
|
|
660
|
+
const visibleBody: string[] = [];
|
|
630
661
|
while (end < rows.length && used + heightAt(end) <= contentBudget) {
|
|
631
|
-
|
|
662
|
+
visibleBody.push(...rows[end].lines);
|
|
632
663
|
used += heightAt(end);
|
|
633
664
|
end++;
|
|
634
665
|
}
|
|
635
666
|
// A selected two-line row must remain addressable even if only one body
|
|
636
667
|
// line is available. Showing its header is preferable to hiding it.
|
|
637
668
|
if (end === start && rows[start] && contentBudget > 0) {
|
|
638
|
-
|
|
669
|
+
visibleBody.push(rows[start].lines[0]);
|
|
639
670
|
end = start + 1;
|
|
640
671
|
}
|
|
641
672
|
|
|
@@ -653,7 +684,44 @@ export class AgentWidget {
|
|
|
653
684
|
...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
|
|
654
685
|
].join(" · ");
|
|
655
686
|
const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
|
|
656
|
-
|
|
687
|
+
|
|
688
|
+
if (showTopMore) lines.push(truncate(theme.fg("dim", `↑ ${hiddenBefore} more`)));
|
|
689
|
+
|
|
690
|
+
// Fill the bounded viewport so the scrollbar track has a stable height.
|
|
691
|
+
while (visibleBody.length < contentBudget) visibleBody.push("");
|
|
692
|
+
const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
|
|
693
|
+
const startOffset = rows.slice(0, start).reduce((total, row) => total + row.lines.length, 0);
|
|
694
|
+
const trackHeight = Math.max(0, contentBudget);
|
|
695
|
+
const minThumbHeight = Math.min(2, trackHeight);
|
|
696
|
+
const thumbHeight = Math.max(
|
|
697
|
+
minThumbHeight,
|
|
698
|
+
Math.min(trackHeight, Math.round((trackHeight * trackHeight) / totalBody)),
|
|
699
|
+
);
|
|
700
|
+
const maxScrollTop = Math.max(0, totalBody - trackHeight);
|
|
701
|
+
const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
|
|
702
|
+
const thumbOffset = maxScrollTop === 0
|
|
703
|
+
? 0
|
|
704
|
+
: Math.round((startOffset / maxScrollTop) * maxThumbTop);
|
|
705
|
+
const contentWidth = Math.max(0, w - 1);
|
|
706
|
+
for (let index = 0; index < visibleBody.length; index++) {
|
|
707
|
+
const isThumb = index >= thumbOffset && index < thumbOffset + thumbHeight;
|
|
708
|
+
const scrollbar = isThumb
|
|
709
|
+
? theme.fg("scrollbarThumb", this.navigationActive ? "█" : "┃")
|
|
710
|
+
: theme.fg("scrollbarTrack", "│");
|
|
711
|
+
const bodyLine = truncate(visibleBody[index] ?? "", contentWidth);
|
|
712
|
+
lines.push(bodyLine + " ".repeat(Math.max(0, contentWidth - visibleWidth(bodyLine))) + scrollbar);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// The directional count is outside the viewport, like ScrollView's
|
|
716
|
+
// surrounding chrome, and therefore does not consume scrollbar track.
|
|
717
|
+
if (hiddenAfter > 0) {
|
|
718
|
+
lines.push(truncate(theme.fg("dim", `↓ ${hiddenAfter} more · +${hidden} more (${categoryCounts.length > 0 ? categoryCounts.join(", ") : ""})`)));
|
|
719
|
+
} else if (!showTopMore) {
|
|
720
|
+
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
|
|
721
|
+
} else {
|
|
722
|
+
// Keep a stable footer row when the selected viewport reaches the end.
|
|
723
|
+
lines.push(truncate(theme.fg("dim", "└─")));
|
|
724
|
+
}
|
|
657
725
|
}
|
|
658
726
|
|
|
659
727
|
return lines;
|
|
@@ -729,6 +797,7 @@ export class AgentWidget {
|
|
|
729
797
|
this.lastRenderKey = undefined;
|
|
730
798
|
this.navigationActive = false;
|
|
731
799
|
this.selectedAgentId = undefined;
|
|
800
|
+
this.selectedRosterIndex = 0;
|
|
732
801
|
this.viewportStart = 0;
|
|
733
802
|
this.syncTimer(false);
|
|
734
803
|
return;
|
|
@@ -753,13 +822,22 @@ export class AgentWidget {
|
|
|
753
822
|
// an otherwise idle widget advance.
|
|
754
823
|
if (advanceSpinner && runningCount > 0) this.widgetFrame++;
|
|
755
824
|
this.syncTimer(runningCount > 0);
|
|
756
|
-
|
|
825
|
+
const selectedIndex = this.selectedIndexOf(roster);
|
|
826
|
+
if (selectedIndex >= 0) {
|
|
827
|
+
this.selectedRosterIndex = selectedIndex;
|
|
828
|
+
} else if (this.navigationActive) {
|
|
757
829
|
if (roster.length === 0) {
|
|
758
830
|
this.navigationActive = false;
|
|
759
831
|
this.selectedAgentId = undefined;
|
|
832
|
+
this.selectedRosterIndex = 0;
|
|
760
833
|
this.viewportStart = 0;
|
|
761
|
-
} else
|
|
762
|
-
|
|
834
|
+
} else {
|
|
835
|
+
// Keep the selection near the row that disappeared. Both the saved
|
|
836
|
+
// logical index and viewport are clamped as the roster shrinks.
|
|
837
|
+
const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, roster.length - 1));
|
|
838
|
+
this.selectedAgentId = roster[fallbackIndex].id;
|
|
839
|
+
this.selectedRosterIndex = fallbackIndex;
|
|
840
|
+
this.viewportStart = Math.min(this.viewportStart, roster.length - 1);
|
|
763
841
|
}
|
|
764
842
|
}
|
|
765
843
|
const renderKey = this.renderKey(allAgents);
|
|
@@ -805,6 +883,7 @@ export class AgentWidget {
|
|
|
805
883
|
this.lastRenderKey = undefined;
|
|
806
884
|
this.navigationActive = false;
|
|
807
885
|
this.selectedAgentId = undefined;
|
|
886
|
+
this.selectedRosterIndex = 0;
|
|
808
887
|
this.viewportStart = 0;
|
|
809
888
|
this.uiCtx = undefined;
|
|
810
889
|
}
|
|
@@ -325,7 +325,10 @@ export class ConversationTimeline implements Component {
|
|
|
325
325
|
|
|
326
326
|
try {
|
|
327
327
|
const markdown = new Markdown(block.markdown || "∅", 2, 0, getMarkdownTheme(), {
|
|
328
|
-
color: (text) => this.theme.fg(
|
|
328
|
+
color: (text) => this.theme.fg(
|
|
329
|
+
block.role === "user" ? "userMessageText" : block.role === "meta" ? "muted" : "text",
|
|
330
|
+
text,
|
|
331
|
+
),
|
|
329
332
|
});
|
|
330
333
|
for (const line of markdown.render(requestedWidth)) {
|
|
331
334
|
lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
|