@aui.io/apollo 0.1.7 → 0.1.10
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/node_modules/@aui.io/apollo-cli/bin/apollo.js +8 -5
- package/node_modules/@aui.io/apollo-cli/dist/src/commands/operate.js +3 -10
- package/node_modules/@aui.io/apollo-cli/dist/src/commands/operate.js.map +1 -1
- package/node_modules/@aui.io/apollo-cli/package.json +3 -3
- package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.d.ts +13 -0
- package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js +17 -0
- package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/hierarchy.js +7 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/hierarchy.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.d.ts +17 -0
- package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js +27 -0
- package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/package.json +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/ActionPalette.d.ts +13 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/ActionPalette.js +47 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/ActionPalette.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.d.ts +28 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js +219 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivityDrawer.d.ts +8 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivityDrawer.js +43 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivityDrawer.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivitySummary.d.ts +5 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivitySummary.js +26 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivitySummary.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/operationStore.d.ts +67 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/operationStore.js +138 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/activity/operationStore.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/AuthorHome.d.ts +27 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/AuthorHome.js +92 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/AuthorHome.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/DiffView.d.ts +46 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/DiffView.js +87 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/DiffView.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/SessionPane.d.ts +15 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/SessionPane.js +38 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/SessionPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/ValidationView.d.ts +11 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/ValidationView.js +34 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/ValidationView.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/VersionsView.d.ts +9 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/VersionsView.js +31 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/VersionsView.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/claudeCode.d.ts +23 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/claudeCode.js +293 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/claudeCode.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/index.d.ts +4 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/index.js +13 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/index.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/sessions.d.ts +3 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/sessions.js +36 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/sessions.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/types.d.ts +85 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/types.js +11 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/types.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/data.d.ts +78 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/data.js +279 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/data.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/session.d.ts +64 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/session.js +319 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/session.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/tree.d.ts +25 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/tree.js +69 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/tree.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/context/ContextSwitcher.d.ts +23 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/context/ContextSwitcher.js +60 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/context/ContextSwitcher.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/index.d.ts +19 -2
- package/node_modules/@aui.io/apollo-tui/dist/src/index.js +24 -2
- package/node_modules/@aui.io/apollo-tui/dist/src/index.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/keyprobe.d.ts +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/keyprobe.js +51 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/keyprobe.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.d.ts +56 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js +1019 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/clipboard.d.ts +7 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/clipboard.js +33 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/clipboard.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.d.ts +10 -4
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js +444 -23
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/confirm.d.ts +10 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/confirm.js +12 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/confirm.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/ide.d.ts +16 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/ide.js +39 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/ide.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/keydiag.d.ts +17 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/keydiag.js +76 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/keydiag.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/keys.d.ts +64 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/keys.js +105 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/keys.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/modes.d.ts +18 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/modes.js +24 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/modes.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/picker.d.ts +28 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/picker.js +72 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/picker.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/place.d.ts +26 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/place.js +32 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/place.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/scope.d.ts +29 -6
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/scope.js +112 -13
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/scope.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/sheet.d.ts +5 -10
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/sheet.js +2 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/sheet.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/opentui-app.js +2106 -273
- package/node_modules/@aui.io/apollo-tui/dist/src/opentui-app.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/Breadcrumb.d.ts +15 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/Breadcrumb.js +22 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/Breadcrumb.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/FooterHints.d.ts +8 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/FooterHints.js +23 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/FooterHints.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/ModeNav.d.ts +4 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/ModeNav.js +7 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/shell/ModeNav.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/Composer.d.ts +20 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/Composer.js +29 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/Composer.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/InspectorPane.d.ts +15 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/InspectorPane.js +27 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/InspectorPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/TalkView.d.ts +30 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/TalkView.js +60 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/TalkView.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/ThreadsPane.d.ts +17 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/ThreadsPane.js +61 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/ThreadsPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/data.d.ts +107 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/data.js +235 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/talk/data.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/theme/tokens.d.ts +8 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/theme/tokens.js +9 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/theme/tokens.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/ConfirmPane.d.ts +4 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/ConfirmPane.js +15 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/ConfirmPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/OutputPane.d.ts +5 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/OutputPane.js +16 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/OutputPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/PickerPane.d.ts +10 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/PickerPane.js +29 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/PickerPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/SheetPane.d.ts +4 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/SheetPane.js +22 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/ui/SheetPane.js.map +1 -0
- package/node_modules/@aui.io/apollo-tui/package.json +2 -2
- package/package.json +4 -4
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/tabs.d.ts +0 -10
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/tabs.js +0 -19
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/tabs.js.map +0 -1
|
@@ -1,192 +1,1668 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* Apollo TUI — OpenTUI engine
|
|
3
|
+
* Apollo TUI — OpenTUI engine, two-mode workbench shell.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Author (program cockpit) and Talk (runtime conversation) are the only
|
|
6
|
+
* primary modes (TUI_DESIGN §6). Business logic stays in apollo-core via
|
|
7
|
+
* lib/actions.ts; this file owns composition, input semantics, and focus.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { watch } from "node:fs";
|
|
10
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
11
|
+
import { createCliRenderer, } from "@opentui/core";
|
|
12
|
+
import { createRoot, useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
12
13
|
import { resolveContext } from "@aui.io/apollo-core/config/index.js";
|
|
13
|
-
import {
|
|
14
|
+
import { listVersions } from "@aui.io/apollo-core/services/bundles.js";
|
|
14
15
|
import { createClients } from "@aui.io/apollo-core/services/clients.js";
|
|
15
|
-
import {
|
|
16
|
-
import { getStatus } from "@aui.io/apollo-core/services/status.service.js";
|
|
17
|
-
import { sendMessage } from "@aui.io/apollo-core/services/messaging.js";
|
|
16
|
+
import { listAgentThreads, listThreads, resolveSendVersion, sendMessage, } from "@aui.io/apollo-core/services/messaging.js";
|
|
18
17
|
import { GlyphController } from "./components/glyph.js";
|
|
19
|
-
import {
|
|
18
|
+
import { applyPick, chatOverlay, destructiveSpec, loadPickerCatalog, refreshContext, runDestructive, runSheetAction, runSimpleCommand, } from "./lib/actions.js";
|
|
19
|
+
import { filterCommands, PLACE_ONBOARD_COMMAND, resolveCommand, SLASH_COMMANDS, } from "./lib/commands.js";
|
|
20
|
+
import { checkConfirm, createConfirm } from "./lib/confirm.js";
|
|
21
|
+
import { copyToClipboard } from "./lib/clipboard.js";
|
|
22
|
+
import { matchActivity, matchArrow, matchContext, matchCtrlC, matchEscape, matchHorizontal, matchModeJump, matchModeToggle, matchNewThread, matchPage, matchPalette, matchTalkTarget, matchTurnDebug, matchTurnTrace, } from "./lib/keys.js";
|
|
23
|
+
import { modeByIndex, modeForJumpTab, otherMode } from "./lib/modes.js";
|
|
24
|
+
import { clampPickerCursor, effectivePickerFilter, filterPickerItems, pickerPageSize, resolvePickerAnswer, } from "./lib/picker.js";
|
|
25
|
+
import { hasPlace } from "./lib/place.js";
|
|
26
|
+
import { applyNames, resolveScopeNames, scopeFromContext, } from "./lib/scope.js";
|
|
20
27
|
import { createSheet, moveFocus, setFocusedValue, validateSheet, visibleFields, } from "./lib/sheet.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
import { actionByAlias, buildActionRegistry, searchActions, } from "./actions/registry.js";
|
|
29
|
+
import { ActionPalettePane } from "./actions/ActionPalette.js";
|
|
30
|
+
import { AuthorHome } from "./author/AuthorHome.js";
|
|
31
|
+
import { agentRootOf, bundleSrcRoot, changeEntries, changesSummary, defaultDiffStyle, fetchRemoteFiles, fileContentDiff, listPlaceFiles, loadAuthorSnapshot, readLocalFiles, readPlaceFile, relativeTime, runValidationRecord, validationSummary, } from "./author/data.js";
|
|
32
|
+
import { ActivityDrawerPane } from "./activity/ActivityDrawer.js";
|
|
33
|
+
import { ActivitySummaryLine } from "./activity/ActivitySummary.js";
|
|
34
|
+
import { isOperationCommand, latestOperation, operationStore, orderedOperations, } from "./activity/operationStore.js";
|
|
35
|
+
import { resolveAuthorBackend, } from "./author/backend/index.js";
|
|
36
|
+
import { loadSessions, saveSession } from "./author/backend/sessions.js";
|
|
37
|
+
import { relativizeEvent, sessionCancel, sessionCreate, sessionEvent, sessionPrompt, sessionRows, unitRowOffset, } from "./author/session.js";
|
|
38
|
+
import { DiffView } from "./author/DiffView.js";
|
|
39
|
+
import { treeRows } from "./author/tree.js";
|
|
40
|
+
import { ValidationViewPane } from "./author/ValidationView.js";
|
|
41
|
+
import { VersionsViewPane } from "./author/VersionsView.js";
|
|
42
|
+
import { CONTEXT_ROWS, ContextSwitcherPane, } from "./context/ContextSwitcher.js";
|
|
43
|
+
import { Breadcrumb } from "./shell/Breadcrumb.js";
|
|
44
|
+
import { FooterHints } from "./shell/FooterHints.js";
|
|
45
|
+
import { ModeNav } from "./shell/ModeNav.js";
|
|
46
|
+
import { Composer } from "./talk/Composer.js";
|
|
47
|
+
import { agentOrdinal, beginSend, cancelSend, composerDisabledReason, hasPendingSend, inspectorMeta, lastAgentTurnIndex, settleSend, talkLayout, talkRows, threadSummaries, turnRowOffset, } from "./talk/data.js";
|
|
48
|
+
import { NEW_THREAD_ROW, threadsRowCount } from "./talk/ThreadsPane.js";
|
|
49
|
+
import { TalkView } from "./talk/TalkView.js";
|
|
50
|
+
import { ConfirmPane } from "./ui/ConfirmPane.js";
|
|
51
|
+
import { PickerPane } from "./ui/PickerPane.js";
|
|
52
|
+
import { SheetPane } from "./ui/SheetPane.js";
|
|
53
|
+
import { AMBER, DIM, FAINT, FG, TEAL, TEAL_DIM } from "./theme/tokens.js";
|
|
54
|
+
const VERSION = process.env.npm_package_version ?? "0.1.10";
|
|
55
|
+
const REGISTRY = buildActionRegistry();
|
|
56
|
+
/** Author-mode single-letter quick actions (typed + Enter). */
|
|
57
|
+
const AUTHOR_QUICK = {
|
|
58
|
+
v: "validate",
|
|
59
|
+
d: "diff",
|
|
60
|
+
p: "push",
|
|
61
|
+
l: "pull",
|
|
62
|
+
r: "versions",
|
|
63
|
+
o: "ide",
|
|
64
|
+
};
|
|
65
|
+
const OLD_NAV_NAMES = new Set(["chat", "bundle", "mockdb", "scope", "overview"]);
|
|
66
|
+
const AUTHOR_VIEW_FOCUS = {
|
|
67
|
+
diff: "author-diff",
|
|
68
|
+
validation: "author-validation",
|
|
69
|
+
versions: "author-versions",
|
|
70
|
+
};
|
|
71
|
+
function App({ context: initialContext, initialScope, glyph, onQuit }) {
|
|
72
|
+
const [context, setContext] = useState(initialContext);
|
|
73
|
+
const [scope, setScope] = useState(initialScope);
|
|
74
|
+
const [mode, setMode] = useState("author");
|
|
39
75
|
const [input, setInput] = useState("");
|
|
40
76
|
const [chat, setChat] = useState([]);
|
|
41
|
-
const [
|
|
42
|
-
const [
|
|
43
|
-
const [
|
|
77
|
+
const [threadId, setThreadId] = useState();
|
|
78
|
+
const [toast, setToast] = useState();
|
|
79
|
+
const [sheet, setSheet] = useState();
|
|
80
|
+
const [sheetCommand, setSheetCommand] = useState();
|
|
81
|
+
const [picker, setPicker] = useState();
|
|
82
|
+
const [confirm, setConfirm] = useState();
|
|
83
|
+
const [paletteCursor, setPaletteCursor] = useState();
|
|
84
|
+
const [switcherCursor, setSwitcherCursor] = useState();
|
|
85
|
+
/** Activity drawer cursor; undefined = closed (^J toggles). */
|
|
86
|
+
const [activityCursor, setActivityCursor] = useState();
|
|
87
|
+
const [paneTitle, setPaneTitle] = useState();
|
|
88
|
+
const [paneLines, setPaneLines] = useState([]);
|
|
44
89
|
const [working, setWorking] = useState(false);
|
|
45
90
|
const [trace, setTrace] = useState(false);
|
|
46
91
|
const [localMode, setLocalMode] = useState(false);
|
|
92
|
+
/**
|
|
93
|
+
* Session talk target: undefined = follow the `.apollorc` binding,
|
|
94
|
+
* "latest" = force the live version, else an explicit tag. Never written
|
|
95
|
+
* to disk — pinning a tag for a chat must not move the author baseline.
|
|
96
|
+
*/
|
|
97
|
+
const [talkTag, setTalkTag] = useState(undefined);
|
|
47
98
|
const [vars, setVars] = useState([]);
|
|
48
99
|
const [frame, setFrame] = useState(glyph.frame());
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
100
|
+
// Author workbench state (Phase 3).
|
|
101
|
+
const [authorView, setAuthorView] = useState("home");
|
|
102
|
+
const [snapshot, setSnapshot] = useState();
|
|
103
|
+
const [validation, setValidation] = useState();
|
|
104
|
+
const [versions, setVersions] = useState();
|
|
105
|
+
/** Cursor over the visible file-tree rows in the diff/browse sidebar. */
|
|
106
|
+
const [diffCursor, setDiffCursor] = useState(0);
|
|
107
|
+
/** Full-width single-file preview (Enter on a file); Esc collapses. */
|
|
108
|
+
const [fileDiff, setFileDiff] = useState();
|
|
109
|
+
/** Local+remote contents backing diff content panes (loaded on entry). */
|
|
110
|
+
const [diffFiles, setDiffFiles] = useState();
|
|
111
|
+
/** Explicit split/unified override; undefined = auto by terminal width. */
|
|
112
|
+
const [diffStyle, setDiffStyle] = useState();
|
|
113
|
+
/** Collapsed dirs in the file tree (keys are compacted dir paths). */
|
|
114
|
+
const [diffCollapsed, setDiffCollapsed] = useState(() => new Set());
|
|
115
|
+
/** Last file the tree cursor rested on — what the preview pane shows. */
|
|
116
|
+
const [previewPath, setPreviewPath] = useState();
|
|
117
|
+
/** Workspace-level browse listing (no agent: tree shows place files). */
|
|
118
|
+
const [placeFiles, setPlaceFiles] = useState();
|
|
119
|
+
/** Async read for browse-mode previews (agent mode is sync from maps). */
|
|
120
|
+
const [browsePreview, setBrowsePreview] = useState();
|
|
121
|
+
const browseCacheRef = useRef(new Map());
|
|
122
|
+
// Author copilot (Phase 5): pluggable backend behind the AuthorBackend seam.
|
|
123
|
+
const authorBackend = useMemo(() => resolveAuthorBackend(), []);
|
|
124
|
+
const [backendDetection, setBackendDetection] = useState();
|
|
125
|
+
/** Multi-turn session transcript (work units); survives across turns. */
|
|
126
|
+
const [authorSession, setAuthorSession] = useState();
|
|
127
|
+
/** Cursor over session units; Enter expands action units (input/result). */
|
|
128
|
+
const [sessionCursor, setSessionCursor] = useState(0);
|
|
129
|
+
const [sessionExpanded, setSessionExpanded] = useState(() => new Set());
|
|
130
|
+
/** Messages typed while a turn runs — sent in order when it finishes. */
|
|
131
|
+
const [queuedPrompts, setQueuedPrompts] = useState([]);
|
|
132
|
+
// Talk workbench state (Phase 6, TUI_DESIGN §9).
|
|
133
|
+
/** ←/→ move focus between the threads pane and the conversation. */
|
|
134
|
+
const [talkFocus, setTalkFocus] = useState("chat");
|
|
135
|
+
/** Selected turn; the inspector describes it, ^T/^D fetch its payloads. */
|
|
136
|
+
const [talkCursor, setTalkCursor] = useState(0);
|
|
137
|
+
const [threads, setThreads] = useState();
|
|
138
|
+
const [threadsLoading, setThreadsLoading] = useState(false);
|
|
139
|
+
/** Row 0 is "new thread"; real threads follow. */
|
|
140
|
+
const [threadCursor, setThreadCursor] = useState(0);
|
|
141
|
+
/** Trace/debug fetched for one turn; cleared when the selection moves. */
|
|
142
|
+
const [inspectorFetch, setInspectorFetch] = useState();
|
|
143
|
+
/** Mirror of the composer textarea (emptiness gates arrow-key semantics). */
|
|
144
|
+
const [composer, setComposer] = useState("");
|
|
145
|
+
const composerRef = useRef(null);
|
|
146
|
+
const inspectorScrollRef = useRef(null);
|
|
147
|
+
/** Drafts survive thread switches; keyed by thread id ("new" = unstarted). */
|
|
148
|
+
const draftsRef = useRef(new Map());
|
|
149
|
+
/**
|
|
150
|
+
* Send epoch — the messaging API cannot cancel server-side, so cancelling
|
|
151
|
+
* (or switching threads) bumps this and the stale reply is discarded.
|
|
152
|
+
*/
|
|
153
|
+
const sendSeqRef = useRef(0);
|
|
154
|
+
const turnHandleRef = useRef(undefined);
|
|
155
|
+
/** Operation id of the active turn — also the "is this turn stale" guard. */
|
|
156
|
+
const turnOpIdRef = useRef(undefined);
|
|
157
|
+
/** Full feed mirror for the operation drawer's output copy. */
|
|
158
|
+
const turnFeedRef = useRef([]);
|
|
159
|
+
/** The one scrollable content pane on screen (diff/validation/versions/talk). */
|
|
160
|
+
const contentScrollRef = useRef(null);
|
|
161
|
+
/** Remote bundle contents cached per bound tag (content diffs). */
|
|
162
|
+
const remoteFilesRef = useRef(undefined);
|
|
163
|
+
/** Detects agent switches to reset per-agent author state. */
|
|
164
|
+
const agentKeyRef = useRef(undefined);
|
|
165
|
+
/** Soft place gate offered once per session (pick / skip / already had place). */
|
|
166
|
+
const placeGateOffered = useRef(false);
|
|
167
|
+
const { width: termWidth } = useTerminalDimensions();
|
|
168
|
+
/** Effective diff style: explicit toggle wins, else width decides. */
|
|
169
|
+
const diffStyleEffective = diffStyle ?? defaultDiffStyle(termWidth);
|
|
170
|
+
// Durable operations (TUI_DESIGN §13): the store outlives view switches;
|
|
171
|
+
// this subscription only renders what it holds.
|
|
172
|
+
const operations = useSyncExternalStore(operationStore.subscribe, operationStore.snapshot);
|
|
173
|
+
const orderedOps = useMemo(() => orderedOperations(operations), [operations]);
|
|
174
|
+
const latestOp = latestOperation(operations);
|
|
175
|
+
// Elapsed-time tick while anything runs or the drawer shows ages.
|
|
176
|
+
const hasRunningOp = operations.some((op) => op.status === "running");
|
|
177
|
+
const [, setActivityTick] = useState(0);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!hasRunningOp && activityCursor === undefined)
|
|
180
|
+
return;
|
|
181
|
+
const id = setInterval(() => setActivityTick((t) => t + 1), 1000);
|
|
182
|
+
return () => clearInterval(id);
|
|
183
|
+
}, [hasRunningOp, activityCursor]);
|
|
184
|
+
const hasAgent = scope.agent !== "—";
|
|
185
|
+
/** Changed paths → status, from the author snapshot. */
|
|
186
|
+
const statusByPath = useMemo(() => {
|
|
187
|
+
const map = new Map();
|
|
188
|
+
for (const entry of changeEntries(snapshot?.diff)) {
|
|
189
|
+
map.set(entry.path, entry.status);
|
|
190
|
+
}
|
|
191
|
+
return map;
|
|
192
|
+
}, [snapshot]);
|
|
193
|
+
/**
|
|
194
|
+
* What the sidebar tree lists: changed files when an agent has changes,
|
|
195
|
+
* the whole checkout when clean, the place's files at workspace level.
|
|
196
|
+
*/
|
|
197
|
+
const treeEntries = useMemo(() => {
|
|
198
|
+
if (hasAgent) {
|
|
199
|
+
const changed = changeEntries(snapshot?.diff);
|
|
200
|
+
if (changed.length > 0)
|
|
201
|
+
return changed;
|
|
202
|
+
if (diffFiles?.status === "ready") {
|
|
203
|
+
return Object.keys(diffFiles.local)
|
|
204
|
+
.sort()
|
|
205
|
+
.map((path) => ({ path }));
|
|
206
|
+
}
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
return (placeFiles ?? []).map((path) => ({ path }));
|
|
210
|
+
}, [hasAgent, snapshot, diffFiles, placeFiles]);
|
|
211
|
+
const diffRows = useMemo(() => treeRows(treeEntries, diffCollapsed), [treeEntries, diffCollapsed]);
|
|
212
|
+
/** Session rows are pre-wrapped to the pane width → exact scroll offsets. */
|
|
213
|
+
const sessionPaneWidth = Math.max(30, termWidth - 8);
|
|
214
|
+
const sessionRowsView = useMemo(() => authorSession
|
|
215
|
+
? sessionRows(authorSession, sessionExpanded, sessionPaneWidth, queuedPrompts)
|
|
216
|
+
: [], [authorSession, sessionExpanded, sessionPaneWidth, queuedPrompts]);
|
|
217
|
+
// Talk workbench derivations: panes by width, pre-wrapped rows, pending.
|
|
218
|
+
const talkLayoutEffective = useMemo(() => talkLayout(termWidth), [termWidth]);
|
|
219
|
+
const talkPending = hasPendingSend(chat);
|
|
220
|
+
/** Ticks the pending row's elapsed time (and header ages) while waiting. */
|
|
221
|
+
const [talkTick, setTalkTick] = useState(0);
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!talkPending)
|
|
224
|
+
return;
|
|
225
|
+
const id = setInterval(() => setTalkTick((t) => t + 1), 500);
|
|
226
|
+
return () => clearInterval(id);
|
|
227
|
+
}, [talkPending]);
|
|
228
|
+
const conversationWidth = Math.max(28, termWidth -
|
|
229
|
+
8 -
|
|
230
|
+
(talkLayoutEffective.threads ? talkLayoutEffective.threadsWidth : 0) -
|
|
231
|
+
(talkLayoutEffective.inspector ? talkLayoutEffective.inspectorWidth : 0));
|
|
232
|
+
const talkRowsView = useMemo(() => talkRows(chat, conversationWidth, Date.now()),
|
|
233
|
+
// talkTick re-times the pending row twice a second.
|
|
234
|
+
[chat, conversationWidth, talkTick]);
|
|
235
|
+
const talkCursorClamped = clampPickerCursor(talkCursor, chat.length);
|
|
236
|
+
/** What sends run against, for the composer label and inspector (^B picks). */
|
|
237
|
+
const talkVersionLabel = talkTag === "latest"
|
|
238
|
+
? "live"
|
|
239
|
+
: (talkTag ?? (scope.version !== "—" ? scope.version : "live"));
|
|
240
|
+
const inspectorMetaLines = useMemo(() => inspectorMeta(chat, talkCursorClamped, {
|
|
241
|
+
localMode,
|
|
242
|
+
remoteTag: talkVersionLabel,
|
|
243
|
+
env: scope.env,
|
|
244
|
+
vars,
|
|
245
|
+
}), [chat, talkCursorClamped, localMode, talkVersionLabel, scope.env, vars]);
|
|
246
|
+
const composerReason = composerDisabledReason(scope, localMode, scope.agentPath);
|
|
247
|
+
const stateRef = useRef({
|
|
248
|
+
mode,
|
|
249
|
+
sheet,
|
|
250
|
+
sheetCommand,
|
|
251
|
+
picker,
|
|
252
|
+
confirm,
|
|
253
|
+
paletteCursor,
|
|
254
|
+
switcherCursor,
|
|
255
|
+
activityCursor,
|
|
256
|
+
context,
|
|
257
|
+
input,
|
|
258
|
+
scope,
|
|
259
|
+
authorView,
|
|
260
|
+
snapshot,
|
|
261
|
+
validation,
|
|
262
|
+
diffCursor,
|
|
263
|
+
fileDiff,
|
|
264
|
+
diffFiles,
|
|
265
|
+
diffRows,
|
|
266
|
+
diffStyleEffective,
|
|
267
|
+
orderedOps,
|
|
268
|
+
authorSession,
|
|
269
|
+
sessionCursor,
|
|
270
|
+
queuedPrompts,
|
|
271
|
+
backendDetection,
|
|
272
|
+
chat,
|
|
273
|
+
threadId,
|
|
274
|
+
localMode,
|
|
275
|
+
talkTag,
|
|
276
|
+
vars,
|
|
277
|
+
composer,
|
|
278
|
+
talkFocus,
|
|
279
|
+
talkCursor,
|
|
280
|
+
threads,
|
|
281
|
+
threadCursor,
|
|
282
|
+
talkPending,
|
|
283
|
+
inspectorFetch,
|
|
284
|
+
talkLayout: talkLayoutEffective,
|
|
285
|
+
});
|
|
286
|
+
stateRef.current = {
|
|
287
|
+
mode,
|
|
288
|
+
sheet,
|
|
289
|
+
sheetCommand,
|
|
290
|
+
picker,
|
|
291
|
+
confirm,
|
|
292
|
+
paletteCursor,
|
|
293
|
+
switcherCursor,
|
|
294
|
+
activityCursor,
|
|
295
|
+
context,
|
|
296
|
+
input,
|
|
297
|
+
scope,
|
|
298
|
+
authorView,
|
|
299
|
+
snapshot,
|
|
300
|
+
validation,
|
|
301
|
+
diffCursor,
|
|
302
|
+
fileDiff,
|
|
303
|
+
diffFiles,
|
|
304
|
+
diffRows,
|
|
305
|
+
diffStyleEffective,
|
|
306
|
+
orderedOps,
|
|
307
|
+
authorSession,
|
|
308
|
+
sessionCursor,
|
|
309
|
+
queuedPrompts,
|
|
310
|
+
backendDetection,
|
|
311
|
+
chat,
|
|
312
|
+
threadId,
|
|
313
|
+
localMode,
|
|
314
|
+
talkTag,
|
|
315
|
+
vars,
|
|
316
|
+
composer,
|
|
317
|
+
talkFocus,
|
|
318
|
+
talkCursor,
|
|
319
|
+
threads,
|
|
320
|
+
threadCursor,
|
|
321
|
+
talkPending,
|
|
322
|
+
inspectorFetch,
|
|
323
|
+
talkLayout: talkLayoutEffective,
|
|
324
|
+
};
|
|
325
|
+
const paletteOpen = paletteCursor !== undefined;
|
|
326
|
+
const switcherOpen = switcherCursor !== undefined;
|
|
327
|
+
const activityOpen = activityCursor !== undefined;
|
|
328
|
+
const authorEmpty = !scope.loggedIn || scope.agent === "—";
|
|
329
|
+
/** Where the browse tree roots without an agent (workspace level). */
|
|
330
|
+
const browseRoot = bundleSrcRoot(context) ?? context.workspaceRoot;
|
|
331
|
+
/** Subviews need an agent — except diff, which browses any place. */
|
|
332
|
+
const authorViewEffective = authorEmpty && !(authorView === "diff" && browseRoot) ? "home" : authorView;
|
|
54
333
|
useEffect(() => {
|
|
55
|
-
if (
|
|
334
|
+
if (mode !== "author" || !authorEmpty)
|
|
56
335
|
return;
|
|
57
336
|
const id = setInterval(() => setFrame(glyph.frame()), 33);
|
|
58
337
|
return () => clearInterval(id);
|
|
59
|
-
}, [
|
|
338
|
+
}, [mode, authorEmpty, glyph]);
|
|
60
339
|
useEffect(() => {
|
|
61
340
|
glyph.setWorking(working);
|
|
62
341
|
}, [working, glyph]);
|
|
63
|
-
|
|
64
|
-
|
|
342
|
+
// Human names for the strip (workspace manifest, project/org APIs) —
|
|
343
|
+
// ids render immediately, names overlay when resolved; stale results
|
|
344
|
+
// (context changed meanwhile) are dropped.
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
let live = true;
|
|
347
|
+
void resolveScopeNames(context).then((names) => {
|
|
348
|
+
if (live)
|
|
349
|
+
setScope((s) => applyNames(s, names));
|
|
350
|
+
});
|
|
351
|
+
return () => {
|
|
352
|
+
live = false;
|
|
353
|
+
};
|
|
354
|
+
}, [context]);
|
|
355
|
+
// Author snapshot follows the context; switching agent (or account — the
|
|
356
|
+
// profile/env pair scopes threads and baselines too) resets per-agent state.
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
const key = `${context.profile}:${context.environment}:${context.agentId ?? ""}:${context.agentRoot ?? ""}`;
|
|
359
|
+
if (agentKeyRef.current !== key) {
|
|
360
|
+
agentKeyRef.current = key;
|
|
361
|
+
setValidation(undefined);
|
|
362
|
+
setVersions(undefined);
|
|
363
|
+
setFileDiff(undefined);
|
|
364
|
+
setDiffFiles(undefined);
|
|
365
|
+
setDiffCursor(0);
|
|
366
|
+
setDiffCollapsed(new Set());
|
|
367
|
+
setPreviewPath(undefined);
|
|
368
|
+
setPlaceFiles(undefined);
|
|
369
|
+
setBrowsePreview(undefined);
|
|
370
|
+
browseCacheRef.current.clear();
|
|
371
|
+
setAuthorView("home");
|
|
372
|
+
remoteFilesRef.current = undefined;
|
|
373
|
+
// The session belongs to the previous checkout — stop and clear it.
|
|
374
|
+
turnHandleRef.current?.cancel();
|
|
375
|
+
turnHandleRef.current = undefined;
|
|
376
|
+
setAuthorSession(undefined);
|
|
377
|
+
setSessionCursor(0);
|
|
378
|
+
setSessionExpanded(new Set());
|
|
379
|
+
setQueuedPrompts([]);
|
|
380
|
+
// The conversation was with the previous agent/account — a reply
|
|
381
|
+
// arriving now would be misattributed. Discard and start clean.
|
|
382
|
+
sendSeqRef.current += 1;
|
|
383
|
+
setChat([]);
|
|
384
|
+
setThreadId(undefined);
|
|
385
|
+
setThreads(undefined);
|
|
386
|
+
setTalkCursor(0);
|
|
387
|
+
setThreadCursor(0);
|
|
388
|
+
setTalkFocus("chat");
|
|
389
|
+
setInspectorFetch(undefined);
|
|
390
|
+
// Local mode targets one checkout and a pinned tag targets one agent —
|
|
391
|
+
// carrying either to the next target would silently run something the
|
|
392
|
+
// user never chose.
|
|
393
|
+
setLocalMode(false);
|
|
394
|
+
setTalkTag(undefined);
|
|
395
|
+
}
|
|
396
|
+
let cancelled = false;
|
|
397
|
+
void loadAuthorSnapshot(context).then((snap) => {
|
|
398
|
+
if (!cancelled)
|
|
399
|
+
setSnapshot(snap);
|
|
400
|
+
});
|
|
401
|
+
return () => {
|
|
402
|
+
cancelled = true;
|
|
403
|
+
};
|
|
404
|
+
}, [context]);
|
|
405
|
+
// Author backend detection — once per session; failures render an honest
|
|
406
|
+
// "not detected" card, never a broken input.
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
let cancelled = false;
|
|
409
|
+
void authorBackend.detect().then((detection) => {
|
|
410
|
+
if (!cancelled)
|
|
411
|
+
setBackendDetection(detection);
|
|
412
|
+
});
|
|
413
|
+
return () => {
|
|
414
|
+
cancelled = true;
|
|
415
|
+
};
|
|
416
|
+
}, [authorBackend]);
|
|
417
|
+
// Filesystem watcher: saves in the IDE refresh the changes list and mark
|
|
418
|
+
// validation results stale (TUI_DESIGN §8.6 — filesystem is truth).
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const root = bundleSrcRoot(context);
|
|
421
|
+
if (!root)
|
|
422
|
+
return;
|
|
423
|
+
let timer;
|
|
424
|
+
let watcher;
|
|
425
|
+
try {
|
|
426
|
+
watcher = watch(root, { recursive: true }, () => {
|
|
427
|
+
if (timer)
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
timer = setTimeout(() => {
|
|
430
|
+
setValidation((v) => (v && !v.stale ? { ...v, stale: true } : v));
|
|
431
|
+
void loadAuthorSnapshot(stateRef.current.context).then(setSnapshot);
|
|
432
|
+
// Keep open diff panes honest: refresh the local side on save.
|
|
433
|
+
if (stateRef.current.diffFiles?.status === "ready") {
|
|
434
|
+
void readLocalFiles(stateRef.current.context).then((local) => setDiffFiles((s) => (s?.status === "ready" ? { ...s, local } : s)));
|
|
435
|
+
}
|
|
436
|
+
}, 400);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Recursive watch unsupported — snapshot still refreshes after actions.
|
|
441
|
+
}
|
|
442
|
+
return () => {
|
|
443
|
+
if (timer)
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
watcher?.close();
|
|
446
|
+
};
|
|
447
|
+
}, [context]);
|
|
448
|
+
// The preview pane follows the tree cursor: resting on a file selects it
|
|
449
|
+
// (dirs keep the last file up, so browsing feels stable, not flashy).
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
const row = diffRows[clampPickerCursor(diffCursor, diffRows.length)];
|
|
452
|
+
if (row?.kind === "file")
|
|
453
|
+
setPreviewPath(row.path);
|
|
454
|
+
}, [diffCursor, diffRows]);
|
|
455
|
+
// Session follow: new units keep the cursor pinned to the newest unit —
|
|
456
|
+
// unless the user walked it up to read history.
|
|
457
|
+
const sessionLenRef = useRef(0);
|
|
458
|
+
const sessionCursorRef = useRef(0);
|
|
459
|
+
sessionCursorRef.current = sessionCursor;
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
const len = authorSession?.units.length ?? 0;
|
|
462
|
+
const prev = sessionLenRef.current;
|
|
463
|
+
sessionLenRef.current = len;
|
|
464
|
+
if (len === 0 || len === prev)
|
|
465
|
+
return;
|
|
466
|
+
if (prev === 0 || sessionCursorRef.current >= prev - 1) {
|
|
467
|
+
setSessionCursor(len - 1);
|
|
468
|
+
}
|
|
469
|
+
}, [authorSession]);
|
|
470
|
+
// Keep the transcript visible: cursor on the newest unit pins the pane to
|
|
471
|
+
// the bottom (streaming rows grow below the unit's label — bottom-pinning
|
|
472
|
+
// keeps live text on screen); browsing history centers the selected unit.
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (mode !== "author" || authorViewEffective !== "home")
|
|
475
|
+
return;
|
|
476
|
+
if (!authorSession || authorSession.units.length === 0)
|
|
477
|
+
return;
|
|
478
|
+
const box = contentScrollRef.current;
|
|
479
|
+
if (!box)
|
|
480
|
+
return;
|
|
481
|
+
const count = authorSession.units.length;
|
|
482
|
+
const cursor = clampPickerCursor(sessionCursor, count);
|
|
483
|
+
const viewport = Math.max(4, box.height || 10);
|
|
484
|
+
if (cursor >= count - 1) {
|
|
485
|
+
box.scrollTo({
|
|
486
|
+
x: 0,
|
|
487
|
+
y: Math.max(0, sessionRowsView.length - viewport + 1),
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const offset = unitRowOffset(sessionRowsView, cursor);
|
|
492
|
+
box.scrollTo({ x: 0, y: Math.max(0, offset - Math.floor(viewport / 2)) });
|
|
493
|
+
}, [sessionCursor, sessionRowsView, authorSession, mode, authorViewEffective]);
|
|
494
|
+
// Queued prompts drain in order once the running turn settles. (Effect,
|
|
495
|
+
// not inline in onEvent: stateRef sees the settled session only after the
|
|
496
|
+
// next render — the running guard in startAuthorTurn needs it fresh.)
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
if (!authorSession || authorSession.running)
|
|
499
|
+
return;
|
|
500
|
+
if (queuedPrompts.length === 0)
|
|
501
|
+
return;
|
|
502
|
+
const [next, ...rest] = queuedPrompts;
|
|
503
|
+
setQueuedPrompts(rest);
|
|
504
|
+
if (next)
|
|
505
|
+
void startAuthorTurn(next);
|
|
506
|
+
}, [authorSession, queuedPrompts]);
|
|
507
|
+
// Talk cursor follow: new turns pull the selection to the newest one —
|
|
508
|
+
// unless the user walked up to read history.
|
|
509
|
+
const chatLenRef = useRef(0);
|
|
510
|
+
const talkCursorRef = useRef(0);
|
|
511
|
+
talkCursorRef.current = talkCursor;
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
const len = chat.length;
|
|
514
|
+
const prev = chatLenRef.current;
|
|
515
|
+
chatLenRef.current = len;
|
|
516
|
+
if (len === 0 || len === prev)
|
|
517
|
+
return;
|
|
518
|
+
if (prev === 0 || talkCursorRef.current >= prev - 1) {
|
|
519
|
+
setTalkCursor(len - 1);
|
|
520
|
+
}
|
|
521
|
+
}, [chat]);
|
|
522
|
+
// Conversation scroll follows the selection: newest turn pins the pane to
|
|
523
|
+
// the bottom (the pending row grows there); history centers the selection.
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
if (mode !== "talk" || chat.length === 0)
|
|
526
|
+
return;
|
|
527
|
+
const box = contentScrollRef.current;
|
|
528
|
+
if (!box)
|
|
529
|
+
return;
|
|
530
|
+
const cursor = clampPickerCursor(talkCursor, chat.length);
|
|
531
|
+
const viewport = Math.max(4, box.height || 10);
|
|
532
|
+
if (cursor >= chat.length - 1) {
|
|
533
|
+
box.scrollTo({ x: 0, y: Math.max(0, talkRowsView.length - viewport + 1) });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const offset = turnRowOffset(talkRowsView, cursor);
|
|
537
|
+
box.scrollTo({ x: 0, y: Math.max(0, offset - Math.floor(viewport / 2)) });
|
|
538
|
+
}, [talkCursor, talkRowsView, chat, mode]);
|
|
539
|
+
// Threads pane data: lazy, once per context (invalidated on new threads).
|
|
540
|
+
// Scoped to the selected agent when one is bound — a per-user listing
|
|
541
|
+
// mixes every agent's conversations; without an agent it is the fallback.
|
|
542
|
+
// Staleness rides a sequence ref, NOT an effect cleanup: `setThreadsLoading`
|
|
543
|
+
// re-runs this effect, and a cleanup-based cancel flag would cancel the
|
|
544
|
+
// fetch it just started — the pane then spins forever.
|
|
545
|
+
const threadsLoadSeq = useRef(0);
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (mode !== "talk" || !scope.loggedIn)
|
|
548
|
+
return;
|
|
549
|
+
if (threads !== undefined)
|
|
550
|
+
return;
|
|
551
|
+
const seq = ++threadsLoadSeq.current;
|
|
552
|
+
setThreadsLoading(true);
|
|
553
|
+
const clients = createClients(context);
|
|
554
|
+
const load = context.agentId
|
|
555
|
+
? listAgentThreads(context, clients.runtime)
|
|
556
|
+
: listThreads(context, clients.runtime);
|
|
557
|
+
void load
|
|
558
|
+
.then((list) => {
|
|
559
|
+
if (seq === threadsLoadSeq.current)
|
|
560
|
+
setThreads(threadSummaries(list));
|
|
561
|
+
})
|
|
562
|
+
.catch(() => {
|
|
563
|
+
if (seq === threadsLoadSeq.current)
|
|
564
|
+
setThreads([]); // "no threads yet"
|
|
565
|
+
})
|
|
566
|
+
.finally(() => {
|
|
567
|
+
if (seq === threadsLoadSeq.current)
|
|
568
|
+
setThreadsLoading(false);
|
|
569
|
+
});
|
|
570
|
+
}, [mode, scope.loggedIn, threads, context]);
|
|
571
|
+
// The fetched trace/debug payload belongs to one turn — moving the
|
|
572
|
+
// selection retires it (meta always re-derives).
|
|
573
|
+
useEffect(() => {
|
|
574
|
+
setInspectorFetch((f) => (f && f.turn !== talkCursor ? undefined : f));
|
|
575
|
+
}, [talkCursor]);
|
|
576
|
+
// A shrinking terminal can hide the threads pane while it holds focus.
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
if (!talkLayoutEffective.threads && talkFocus === "threads") {
|
|
579
|
+
setTalkFocus("chat");
|
|
580
|
+
}
|
|
581
|
+
}, [talkLayoutEffective.threads, talkFocus]);
|
|
582
|
+
const previewTarget = fileDiff?.path ?? previewPath;
|
|
583
|
+
// Workspace-level browse listing follows the place (covers entering the
|
|
584
|
+
// view and switching workspaces while it is open).
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
if (authorView !== "diff" || hasAgent || !browseRoot)
|
|
587
|
+
return;
|
|
588
|
+
let cancelled = false;
|
|
589
|
+
void listPlaceFiles(browseRoot).then((files) => {
|
|
590
|
+
if (!cancelled)
|
|
591
|
+
setPlaceFiles(files);
|
|
592
|
+
});
|
|
593
|
+
return () => {
|
|
594
|
+
cancelled = true;
|
|
595
|
+
};
|
|
596
|
+
}, [authorView, hasAgent, browseRoot]);
|
|
597
|
+
// Browse mode (no agent): read files on demand, cached per root+path.
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
if (hasAgent || !previewTarget || !browseRoot)
|
|
600
|
+
return;
|
|
601
|
+
const cacheKey = `${browseRoot}:${previewTarget}`;
|
|
602
|
+
const cached = browseCacheRef.current.get(cacheKey);
|
|
603
|
+
if (cached) {
|
|
604
|
+
setBrowsePreview({ path: previewTarget, lines: cached });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
let cancelled = false;
|
|
608
|
+
setBrowsePreview({ path: previewTarget });
|
|
609
|
+
void readPlaceFile(browseRoot, previewTarget)
|
|
610
|
+
.then((lines) => {
|
|
611
|
+
browseCacheRef.current.set(cacheKey, lines);
|
|
612
|
+
if (!cancelled)
|
|
613
|
+
setBrowsePreview({ path: previewTarget, lines });
|
|
614
|
+
})
|
|
615
|
+
.catch((error) => {
|
|
616
|
+
if (!cancelled) {
|
|
617
|
+
setBrowsePreview({
|
|
618
|
+
path: previewTarget,
|
|
619
|
+
error: error instanceof Error ? error.message : String(error),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
return () => {
|
|
624
|
+
cancelled = true;
|
|
625
|
+
};
|
|
626
|
+
}, [previewTarget, hasAgent, browseRoot]);
|
|
627
|
+
/** Content diff for the selected changed file (agent mode, remote known). */
|
|
628
|
+
const previewDiffLines = useMemo(() => {
|
|
629
|
+
if (!previewTarget || !hasAgent)
|
|
630
|
+
return undefined;
|
|
631
|
+
if (diffFiles?.status !== "ready" || diffFiles.remoteError)
|
|
632
|
+
return undefined;
|
|
633
|
+
if (!statusByPath.has(previewTarget))
|
|
634
|
+
return undefined;
|
|
635
|
+
return fileContentDiff(previewTarget, diffFiles.local, diffFiles.remote);
|
|
636
|
+
}, [previewTarget, hasAgent, diffFiles, statusByPath]);
|
|
637
|
+
/** What the preview pane renders — memoized so pane memoization holds. */
|
|
638
|
+
const preview = useMemo(() => {
|
|
639
|
+
if (!previewTarget)
|
|
640
|
+
return { kind: "none" };
|
|
641
|
+
if (!hasAgent) {
|
|
642
|
+
if (browsePreview?.path !== previewTarget) {
|
|
643
|
+
return { kind: "loading", path: previewTarget };
|
|
644
|
+
}
|
|
645
|
+
if (browsePreview.error) {
|
|
646
|
+
return { kind: "error", path: previewTarget, message: browsePreview.error };
|
|
647
|
+
}
|
|
648
|
+
return browsePreview.lines
|
|
649
|
+
? { kind: "file", path: previewTarget, lines: browsePreview.lines }
|
|
650
|
+
: { kind: "loading", path: previewTarget };
|
|
651
|
+
}
|
|
652
|
+
if (!diffFiles || diffFiles.status === "loading") {
|
|
653
|
+
return { kind: "loading", path: previewTarget };
|
|
654
|
+
}
|
|
655
|
+
if (diffFiles.status === "error") {
|
|
656
|
+
return { kind: "error", path: previewTarget, message: diffFiles.message };
|
|
657
|
+
}
|
|
658
|
+
if (previewDiffLines) {
|
|
659
|
+
return { kind: "diff", path: previewTarget, lines: previewDiffLines };
|
|
660
|
+
}
|
|
661
|
+
const content = diffFiles.local[previewTarget];
|
|
662
|
+
if (content === undefined) {
|
|
663
|
+
return {
|
|
664
|
+
kind: "error",
|
|
665
|
+
path: previewTarget,
|
|
666
|
+
message: diffFiles.remoteError
|
|
667
|
+
? `deleted locally · remote unavailable — ${diffFiles.remoteError}`
|
|
668
|
+
: "file missing locally",
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const note = diffFiles.remoteError && statusByPath.has(previewTarget)
|
|
672
|
+
? `remote unavailable — showing local file (${diffFiles.remoteError})`
|
|
673
|
+
: undefined;
|
|
674
|
+
return {
|
|
675
|
+
kind: "file",
|
|
676
|
+
path: previewTarget,
|
|
677
|
+
lines: content.split("\n"),
|
|
678
|
+
...(note ? { note } : {}),
|
|
679
|
+
};
|
|
680
|
+
}, [previewTarget, hasAgent, browsePreview, diffFiles, previewDiffLines, statusByPath]);
|
|
681
|
+
const paletteMatches = paletteOpen
|
|
682
|
+
? searchActions(REGISTRY, input, mode)
|
|
683
|
+
: [];
|
|
684
|
+
const paletteHighlight = paletteOpen
|
|
685
|
+
? clampPickerCursor(paletteCursor ?? 0, paletteMatches.length)
|
|
686
|
+
: 0;
|
|
687
|
+
// Legacy inline `/` suggestions — kept as a power affordance, not taught.
|
|
688
|
+
const slashOpen = !sheet && !picker && !confirm && !paletteOpen && !switcherOpen && input.startsWith("/");
|
|
689
|
+
const slashMatches = slashOpen ? filterCommands(input).slice(0, 8) : [];
|
|
65
690
|
useKeyboard((key) => {
|
|
66
691
|
if (key.eventType === "release")
|
|
67
692
|
return;
|
|
68
693
|
const current = stateRef.current;
|
|
694
|
+
const arrow = matchArrow(key);
|
|
695
|
+
// Ctrl+C, Claude Code semantics: cancel the running turn (author) or the
|
|
696
|
+
// in-flight send (talk); otherwise clear typed input; on an idle empty
|
|
697
|
+
// prompt, quit. (exitOnCtrlC is off — the app owns this key.)
|
|
698
|
+
if (matchCtrlC(key)) {
|
|
699
|
+
key.preventDefault();
|
|
700
|
+
if (current.mode === "talk" && current.talkPending) {
|
|
701
|
+
cancelTalkSend();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (current.authorSession?.running) {
|
|
705
|
+
cancelAuthorTurn();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (current.mode === "talk" && current.composer) {
|
|
709
|
+
clearComposer();
|
|
710
|
+
setToast("Composer cleared — ^C again to quit");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (current.input) {
|
|
714
|
+
setInput("");
|
|
715
|
+
setToast("Input cleared — ^C again to quit");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
onQuit(0);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (arrow && current.picker) {
|
|
722
|
+
const visible = filterPickerItems(current.picker.items, effectivePickerFilter(current.input));
|
|
723
|
+
if (visible.length === 0)
|
|
724
|
+
return;
|
|
725
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
726
|
+
setPicker({
|
|
727
|
+
...current.picker,
|
|
728
|
+
cursor: clampPickerCursor(current.picker.cursor + delta, visible.length),
|
|
729
|
+
});
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (arrow && current.paletteCursor !== undefined) {
|
|
733
|
+
const matches = searchActions(REGISTRY, current.input, current.mode);
|
|
734
|
+
if (matches.length === 0)
|
|
735
|
+
return;
|
|
736
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
737
|
+
setPaletteCursor(clampPickerCursor(current.paletteCursor + delta, matches.length));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (arrow && current.switcherCursor !== undefined) {
|
|
741
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
742
|
+
setSwitcherCursor(clampPickerCursor(current.switcherCursor + delta, CONTEXT_ROWS.length));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (arrow && current.activityCursor !== undefined) {
|
|
746
|
+
const count = current.orderedOps.length;
|
|
747
|
+
if (count === 0)
|
|
748
|
+
return;
|
|
749
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
750
|
+
setActivityCursor((c) => clampPickerCursor((c ?? 0) + delta, count));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// Activity drawer quick actions — intercepted before the input sees them.
|
|
754
|
+
if (current.activityCursor !== undefined &&
|
|
755
|
+
!key.ctrl &&
|
|
756
|
+
!key.meta &&
|
|
757
|
+
(key.name === "r" || key.name === "c")) {
|
|
758
|
+
key.preventDefault();
|
|
759
|
+
const op = current.orderedOps[clampPickerCursor(current.activityCursor, current.orderedOps.length)];
|
|
760
|
+
if (!op)
|
|
761
|
+
return;
|
|
762
|
+
if (key.name === "r")
|
|
763
|
+
retryOperation(op);
|
|
764
|
+
else
|
|
765
|
+
copyOperationOutput(op);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
// Talk workbench keys (no modal open). Chords first — they work no matter
|
|
769
|
+
// what the composer holds; plain arrows belong to the textarea caret as
|
|
770
|
+
// soon as there is a draft.
|
|
771
|
+
const talkActive = current.mode === "talk" &&
|
|
772
|
+
!current.sheet &&
|
|
773
|
+
!current.confirm &&
|
|
774
|
+
!current.picker &&
|
|
775
|
+
current.paletteCursor === undefined &&
|
|
776
|
+
current.switcherCursor === undefined &&
|
|
777
|
+
current.activityCursor === undefined;
|
|
778
|
+
if (talkActive) {
|
|
779
|
+
if (matchNewThread(key)) {
|
|
780
|
+
key.preventDefault();
|
|
781
|
+
const cmd = SLASH_COMMANDS.find((c) => c.name === "new");
|
|
782
|
+
if (cmd)
|
|
783
|
+
void runCommand(cmd, "");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (matchTalkTarget(key)) {
|
|
787
|
+
// The textarea's default ctrl+b is cursor-back — the target picker
|
|
788
|
+
// wins here (arrow keys cover cursor movement).
|
|
789
|
+
key.preventDefault();
|
|
790
|
+
const cmd = SLASH_COMMANDS.find((c) => c.name === "target");
|
|
791
|
+
if (cmd)
|
|
792
|
+
void runCommand(cmd, "");
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (matchTurnTrace(key)) {
|
|
796
|
+
key.preventDefault();
|
|
797
|
+
void inspectTurn("trace");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (matchTurnDebug(key)) {
|
|
801
|
+
// The textarea's default ctrl+d is delete-forward — ours wins.
|
|
802
|
+
key.preventDefault();
|
|
803
|
+
void inspectTurn("debug");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (current.composer.length === 0) {
|
|
807
|
+
const horizontal = matchHorizontal(key);
|
|
808
|
+
if (horizontal && current.talkLayout.threads) {
|
|
809
|
+
key.preventDefault();
|
|
810
|
+
setTalkFocus(horizontal === "left" ? "threads" : "chat");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (arrow) {
|
|
814
|
+
key.preventDefault();
|
|
815
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
816
|
+
if (current.talkFocus === "threads") {
|
|
817
|
+
const count = threadsRowCount(current.threads);
|
|
818
|
+
setThreadCursor((c) => clampPickerCursor(c + delta, count));
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
if (current.chat.length === 0)
|
|
822
|
+
return;
|
|
823
|
+
setTalkCursor((c) => clampPickerCursor(c + delta, current.chat.length));
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// File-tree navigation in the diff/browse sidebar (no modal open).
|
|
830
|
+
if (arrow &&
|
|
831
|
+
!current.sheet &&
|
|
832
|
+
!current.confirm &&
|
|
833
|
+
current.mode === "author" &&
|
|
834
|
+
current.authorView === "diff" &&
|
|
835
|
+
!current.fileDiff) {
|
|
836
|
+
const rows = current.diffRows;
|
|
837
|
+
if (rows.length === 0)
|
|
838
|
+
return;
|
|
839
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
840
|
+
setDiffCursor((c) => clampPickerCursor(c + delta, rows.length));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
// Session unit cursor (Author home): ↑↓ walk the work units; the pane
|
|
844
|
+
// follows the cursor. Enter (empty input) expands the selected unit.
|
|
845
|
+
if (arrow &&
|
|
846
|
+
!current.sheet &&
|
|
847
|
+
!current.confirm &&
|
|
848
|
+
!current.picker &&
|
|
849
|
+
current.paletteCursor === undefined &&
|
|
850
|
+
current.switcherCursor === undefined &&
|
|
851
|
+
current.activityCursor === undefined &&
|
|
852
|
+
current.mode === "author" &&
|
|
853
|
+
current.authorView === "home" &&
|
|
854
|
+
current.authorSession &&
|
|
855
|
+
current.authorSession.units.length > 0) {
|
|
856
|
+
const count = current.authorSession.units.length;
|
|
857
|
+
const delta = arrow === "down" ? 1 : -1;
|
|
858
|
+
setSessionCursor((c) => clampPickerCursor(c + delta, count));
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
// Arrow-scroll the active content pane (expanded file diff, validation,
|
|
862
|
+
// versions) — the global input keeps focus, so we drive the scrollbox
|
|
863
|
+
// programmatically. Talk arrows select turns instead (handled above).
|
|
864
|
+
if (arrow &&
|
|
865
|
+
!current.sheet &&
|
|
866
|
+
!current.confirm &&
|
|
867
|
+
!current.picker &&
|
|
868
|
+
current.paletteCursor === undefined &&
|
|
869
|
+
current.switcherCursor === undefined &&
|
|
870
|
+
current.activityCursor === undefined &&
|
|
871
|
+
current.mode === "author" &&
|
|
872
|
+
((current.authorView === "diff" && current.fileDiff) ||
|
|
873
|
+
current.authorView === "validation" ||
|
|
874
|
+
current.authorView === "versions")) {
|
|
875
|
+
contentScrollRef.current?.scrollBy({ x: 0, y: arrow === "down" ? 1 : -1 });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
// PgUp/PgDn page-scrolls whatever content pane is on screen (including
|
|
879
|
+
// the sidebar diff preview while arrows drive the file cursor).
|
|
880
|
+
const page = matchPage(key);
|
|
881
|
+
if (page &&
|
|
882
|
+
!current.sheet &&
|
|
883
|
+
!current.confirm &&
|
|
884
|
+
!current.picker &&
|
|
885
|
+
current.paletteCursor === undefined &&
|
|
886
|
+
current.switcherCursor === undefined &&
|
|
887
|
+
current.activityCursor === undefined) {
|
|
888
|
+
contentScrollRef.current?.scrollBy({ x: 0, y: page === "down" ? 10 : -10 });
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const jump = matchModeJump(key);
|
|
892
|
+
if (jump) {
|
|
893
|
+
const def = modeByIndex(jump);
|
|
894
|
+
if (def && !current.confirm) {
|
|
895
|
+
setMode(def.id);
|
|
896
|
+
setInput("");
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (matchPalette(key)) {
|
|
901
|
+
if (current.confirm)
|
|
902
|
+
return;
|
|
903
|
+
if (current.paletteCursor !== undefined) {
|
|
904
|
+
setPaletteCursor(undefined);
|
|
905
|
+
setInput("");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Palette replaces any open picker/sheet (same as Esc first).
|
|
909
|
+
setPicker(undefined);
|
|
910
|
+
setSheet(undefined);
|
|
911
|
+
setSheetCommand(undefined);
|
|
912
|
+
setSwitcherCursor(undefined);
|
|
913
|
+
setActivityCursor(undefined);
|
|
914
|
+
setPaletteCursor(0);
|
|
915
|
+
setInput("");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (matchContext(key)) {
|
|
919
|
+
if (current.confirm)
|
|
920
|
+
return;
|
|
921
|
+
if (current.switcherCursor !== undefined) {
|
|
922
|
+
setSwitcherCursor(undefined);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
setPicker(undefined);
|
|
926
|
+
setSheet(undefined);
|
|
927
|
+
setSheetCommand(undefined);
|
|
928
|
+
setPaletteCursor(undefined);
|
|
929
|
+
setActivityCursor(undefined);
|
|
930
|
+
setSwitcherCursor(0);
|
|
931
|
+
setInput("");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// ^J (or raw linefeed in legacy terminals) toggles the activity drawer.
|
|
935
|
+
// preventDefault keeps the input's linefeed-submit binding from firing —
|
|
936
|
+
// global handlers run before renderable handlers (InternalKeyHandler).
|
|
937
|
+
if (matchActivity(key)) {
|
|
938
|
+
key.preventDefault();
|
|
939
|
+
if (current.confirm || current.picker || current.sheet)
|
|
940
|
+
return;
|
|
941
|
+
if (current.activityCursor !== undefined) {
|
|
942
|
+
setActivityCursor(undefined);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
setPaletteCursor(undefined);
|
|
946
|
+
setSwitcherCursor(undefined);
|
|
947
|
+
setActivityCursor(0);
|
|
948
|
+
setInput("");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
69
951
|
if (key.name === "tab") {
|
|
952
|
+
if (current.picker) {
|
|
953
|
+
const visible = filterPickerItems(current.picker.items, effectivePickerFilter(current.input));
|
|
954
|
+
if (visible.length === 0)
|
|
955
|
+
return;
|
|
956
|
+
setPicker({
|
|
957
|
+
...current.picker,
|
|
958
|
+
cursor: clampPickerCursor(current.picker.cursor + (key.shift ? -1 : 1), visible.length),
|
|
959
|
+
});
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
70
962
|
if (current.sheet) {
|
|
71
963
|
setSheet(moveFocus(current.sheet, key.shift ? -1 : 1));
|
|
964
|
+
return;
|
|
72
965
|
}
|
|
73
|
-
|
|
74
|
-
|
|
966
|
+
if (current.paletteCursor !== undefined) {
|
|
967
|
+
const matches = searchActions(REGISTRY, current.input, current.mode);
|
|
968
|
+
if (matches.length === 0)
|
|
969
|
+
return;
|
|
970
|
+
setPaletteCursor(clampPickerCursor(current.paletteCursor + (key.shift ? -1 : 1), matches.length));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (current.switcherCursor !== undefined) {
|
|
974
|
+
setSwitcherCursor(clampPickerCursor(current.switcherCursor + (key.shift ? -1 : 1), CONTEXT_ROWS.length));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (current.activityCursor !== undefined)
|
|
978
|
+
return;
|
|
979
|
+
if (!current.confirm && matchModeToggle(key)) {
|
|
980
|
+
setMode((m) => otherMode(m));
|
|
75
981
|
setInput("");
|
|
76
982
|
}
|
|
77
983
|
return;
|
|
78
984
|
}
|
|
79
|
-
if (key
|
|
80
|
-
if (current.
|
|
985
|
+
if (matchEscape(key)) {
|
|
986
|
+
if (current.confirm) {
|
|
987
|
+
setConfirm(undefined);
|
|
988
|
+
setToast("Cancelled");
|
|
989
|
+
}
|
|
990
|
+
else if (current.picker) {
|
|
991
|
+
const wasPlaceOnboard = current.picker.command.name === "place-onboard";
|
|
992
|
+
setPicker(undefined);
|
|
993
|
+
if (current.picker.sheet && current.picker.command) {
|
|
994
|
+
setSheet(current.picker.sheet);
|
|
995
|
+
setSheetCommand(current.picker.command);
|
|
996
|
+
}
|
|
997
|
+
setToast(wasPlaceOnboard
|
|
998
|
+
? "Continuing without a workspace — pick one anytime (^G)"
|
|
999
|
+
: "Cancelled");
|
|
1000
|
+
}
|
|
1001
|
+
else if (current.sheet) {
|
|
81
1002
|
setSheet(undefined);
|
|
82
1003
|
setSheetCommand(undefined);
|
|
83
1004
|
setToast("Cancelled");
|
|
84
1005
|
}
|
|
1006
|
+
else if (current.paletteCursor !== undefined) {
|
|
1007
|
+
setPaletteCursor(undefined);
|
|
1008
|
+
setInput("");
|
|
1009
|
+
}
|
|
1010
|
+
else if (current.switcherCursor !== undefined) {
|
|
1011
|
+
setSwitcherCursor(undefined);
|
|
1012
|
+
}
|
|
1013
|
+
else if (current.activityCursor !== undefined) {
|
|
1014
|
+
setActivityCursor(undefined);
|
|
1015
|
+
}
|
|
1016
|
+
else if (current.mode === "author" && current.authorView !== "home") {
|
|
1017
|
+
// Subview back-stack: file diff → file list → home.
|
|
1018
|
+
if (current.fileDiff)
|
|
1019
|
+
setFileDiff(undefined);
|
|
1020
|
+
else
|
|
1021
|
+
setAuthorView("home");
|
|
1022
|
+
}
|
|
1023
|
+
else if (current.mode === "author" && current.authorSession?.running) {
|
|
1024
|
+
cancelAuthorTurn();
|
|
1025
|
+
}
|
|
1026
|
+
else if (current.mode === "talk" && current.inspectorFetch) {
|
|
1027
|
+
setInspectorFetch(undefined);
|
|
1028
|
+
}
|
|
1029
|
+
else if (current.mode === "talk" && current.talkPending) {
|
|
1030
|
+
cancelTalkSend();
|
|
1031
|
+
}
|
|
1032
|
+
else if (current.mode === "talk" && current.composer) {
|
|
1033
|
+
clearComposer();
|
|
1034
|
+
}
|
|
1035
|
+
else if (current.mode === "talk" && current.talkFocus === "threads") {
|
|
1036
|
+
setTalkFocus("chat");
|
|
1037
|
+
}
|
|
85
1038
|
else {
|
|
86
1039
|
setInput("");
|
|
87
1040
|
}
|
|
88
1041
|
return;
|
|
89
1042
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
1043
|
+
});
|
|
1044
|
+
async function applyResult(result) {
|
|
1045
|
+
if (result.error) {
|
|
1046
|
+
setToast(result.error);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (result.openWorkspaceCreate) {
|
|
1050
|
+
const createCmd = resolveCommand("/workspace-create").command;
|
|
1051
|
+
if (createCmd?.fields) {
|
|
1052
|
+
const model = createSheet(createCmd.name, createCmd.fields);
|
|
1053
|
+
setSheet(model);
|
|
1054
|
+
setSheetCommand(createCmd);
|
|
1055
|
+
setToast("Create a workspace — enter a path");
|
|
95
1056
|
}
|
|
1057
|
+
return;
|
|
96
1058
|
}
|
|
97
|
-
|
|
1059
|
+
if (result.toast)
|
|
1060
|
+
setToast(result.toast);
|
|
1061
|
+
const jumpMode = modeForJumpTab(result.jumpTab);
|
|
1062
|
+
if (jumpMode)
|
|
1063
|
+
setMode(jumpMode);
|
|
1064
|
+
if (result.chat) {
|
|
1065
|
+
// Thread switch: park the old draft, restore the destination's, and
|
|
1066
|
+
// discard whatever reply was still in flight for the old thread.
|
|
1067
|
+
const previous = stateRef.current;
|
|
1068
|
+
sendSeqRef.current += 1;
|
|
1069
|
+
draftsRef.current.set(previous.threadId ?? "new", previous.composer);
|
|
1070
|
+
const nextKey = result.threadId ?? (result.clearThread ? "new" : previous.threadId ?? "new");
|
|
1071
|
+
const draft = draftsRef.current.get(nextKey) ?? "";
|
|
1072
|
+
composerRef.current?.setText(draft);
|
|
1073
|
+
setComposer(draft);
|
|
1074
|
+
setChat(result.chat);
|
|
1075
|
+
setTalkCursor(Math.max(0, result.chat.length - 1));
|
|
1076
|
+
setTalkFocus("chat");
|
|
1077
|
+
setInspectorFetch(undefined);
|
|
1078
|
+
// An inline trace/debug overlay belongs to the previous conversation.
|
|
1079
|
+
setPaneTitle((title) => {
|
|
1080
|
+
if (title && /^(trace|debug)/i.test(title)) {
|
|
1081
|
+
setPaneLines([]);
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
return title;
|
|
1085
|
+
});
|
|
1086
|
+
setWorking(false);
|
|
1087
|
+
}
|
|
1088
|
+
if (result.clearThread)
|
|
1089
|
+
setThreadId(undefined);
|
|
1090
|
+
if (result.talkTarget) {
|
|
1091
|
+
const target = result.talkTarget;
|
|
1092
|
+
if (target.kind === "local") {
|
|
1093
|
+
setLocalMode(true);
|
|
1094
|
+
setTalkTag(undefined);
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
setLocalMode(false);
|
|
1098
|
+
setTalkTag(target.kind === "bound"
|
|
1099
|
+
? undefined
|
|
1100
|
+
: target.kind === "live"
|
|
1101
|
+
? "latest"
|
|
1102
|
+
: target.tag);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (result.threadId) {
|
|
1106
|
+
setThreadId(result.threadId);
|
|
1107
|
+
// A thread the pane doesn't know yet → refresh the list lazily.
|
|
1108
|
+
if (!stateRef.current.threads?.some((t) => t.id === result.threadId)) {
|
|
1109
|
+
setThreads(undefined);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (result.paneLines) {
|
|
1113
|
+
setPaneLines(result.paneLines);
|
|
1114
|
+
setPaneTitle(result.paneTitle ?? "result");
|
|
1115
|
+
}
|
|
1116
|
+
if (result.needConfirm) {
|
|
1117
|
+
setConfirm(createConfirm(result.needConfirm.title, result.needConfirm.phrase, result.needConfirm.action, result.needConfirm.args));
|
|
1118
|
+
}
|
|
1119
|
+
if (result.refreshScope) {
|
|
1120
|
+
const next = await refreshContext();
|
|
1121
|
+
setContext(next);
|
|
1122
|
+
setScope(scopeFromContext(next));
|
|
1123
|
+
if (result.offerPlace) {
|
|
1124
|
+
// Account switched somewhere without a workspace — offer the gate
|
|
1125
|
+
// now regardless of whether one was offered at startup.
|
|
1126
|
+
placeGateOffered.current = true;
|
|
1127
|
+
await openPlaceOnboarding(next);
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
await maybeOfferPlace(next);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
async function openPlaceOnboarding(ctx) {
|
|
1135
|
+
await openPick(PLACE_ONBOARD_COMMAND, "place-onboard", undefined, undefined, ctx);
|
|
1136
|
+
}
|
|
1137
|
+
async function maybeOfferPlace(ctx) {
|
|
1138
|
+
if (placeGateOffered.current)
|
|
1139
|
+
return;
|
|
1140
|
+
if (hasPlace(ctx)) {
|
|
1141
|
+
placeGateOffered.current = true;
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
// Logged-out: wait for login (mount toast covers the hint).
|
|
1145
|
+
if (!ctx.token)
|
|
1146
|
+
return;
|
|
1147
|
+
placeGateOffered.current = true;
|
|
1148
|
+
await openPlaceOnboarding(ctx);
|
|
1149
|
+
}
|
|
1150
|
+
useEffect(() => {
|
|
1151
|
+
if (hasPlace(initialContext)) {
|
|
1152
|
+
placeGateOffered.current = true;
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (!initialContext.token) {
|
|
1156
|
+
setToast("Not in a workspace · log in, then pick a place — Esc skips");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
placeGateOffered.current = true;
|
|
1160
|
+
void openPlaceOnboarding();
|
|
1161
|
+
// Once on mount — placeGateOffered guards re-entry.
|
|
1162
|
+
}, []);
|
|
1163
|
+
async function refreshSnapshot() {
|
|
1164
|
+
setSnapshot(await loadAuthorSnapshot(stateRef.current.context));
|
|
1165
|
+
}
|
|
1166
|
+
/** Context lines for push/pull sheets — state the operation will act on. */
|
|
1167
|
+
function sheetInfoFor(name) {
|
|
1168
|
+
const { scope: s, snapshot: snap, validation: v } = stateRef.current;
|
|
1169
|
+
if (name !== "push" && name !== "pull")
|
|
1170
|
+
return undefined;
|
|
1171
|
+
const target = `target ${s.agent}${s.version !== "—" ? ` @ ${s.version}` : ""} · ${s.env}`;
|
|
1172
|
+
if (name === "pull") {
|
|
1173
|
+
const total = changeEntries(snap?.diff).length;
|
|
1174
|
+
return [
|
|
1175
|
+
target,
|
|
1176
|
+
total === 0
|
|
1177
|
+
? "local clean — safe to pull"
|
|
1178
|
+
: `! local ${changesSummary(snap?.diff)} uncommitted — pull will refuse without force`,
|
|
1179
|
+
];
|
|
1180
|
+
}
|
|
1181
|
+
const lines = [
|
|
1182
|
+
target,
|
|
1183
|
+
`changes ${changesSummary(snap?.diff)}${snap?.baselineTag ? ` vs baseline ${snap.baselineTag}` : ""}`,
|
|
1184
|
+
];
|
|
1185
|
+
if (!v) {
|
|
1186
|
+
lines.push("! validation not run — push validates before upload");
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
const errors = v.result.findings.filter((f) => f.level !== "warning").length;
|
|
1190
|
+
if (errors > 0 || !v.result.valid) {
|
|
1191
|
+
lines.push(`! validation ✕ ${errors} error(s) — push will fail until fixed`);
|
|
1192
|
+
}
|
|
1193
|
+
else if (v.stale) {
|
|
1194
|
+
lines.push(`! validation ✓ but stale — files changed since ${relativeTime(v.at)}`);
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
lines.push(`validation ✓ ${relativeTime(v.at)}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return lines;
|
|
1201
|
+
}
|
|
1202
|
+
/** Fetch local + remote contents backing the diff content panes. */
|
|
1203
|
+
async function loadDiffFiles() {
|
|
1204
|
+
const ctx = stateRef.current.context;
|
|
1205
|
+
const tag = ctx.versionTag ?? "";
|
|
1206
|
+
setDiffFiles({ status: "loading" });
|
|
1207
|
+
try {
|
|
1208
|
+
const local = await readLocalFiles(ctx);
|
|
1209
|
+
let remote = remoteFilesRef.current;
|
|
1210
|
+
if (remote?.tag !== tag) {
|
|
1211
|
+
try {
|
|
1212
|
+
const files = await fetchRemoteFiles(ctx, createClients(ctx).management);
|
|
1213
|
+
remote = { tag, files };
|
|
1214
|
+
remoteFilesRef.current = remote;
|
|
1215
|
+
}
|
|
1216
|
+
catch (error) {
|
|
1217
|
+
// Local side is still useful (tree + plain previews) — degrade.
|
|
1218
|
+
setDiffFiles({
|
|
1219
|
+
status: "ready",
|
|
1220
|
+
local,
|
|
1221
|
+
remote: {},
|
|
1222
|
+
tag,
|
|
1223
|
+
remoteError: error instanceof Error ? error.message : String(error),
|
|
1224
|
+
});
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
setDiffFiles({ status: "ready", local, remote: remote.files, tag });
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
setDiffFiles({
|
|
1232
|
+
status: "error",
|
|
1233
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
function openDiffView() {
|
|
1238
|
+
const current = stateRef.current;
|
|
1239
|
+
const agentHere = current.scope.agent !== "—";
|
|
1240
|
+
const root = bundleSrcRoot(current.context) ?? current.context.workspaceRoot;
|
|
1241
|
+
if (!agentHere && !root) {
|
|
1242
|
+
setToast("Nothing to browse — enter a workspace or pick an agent (^G)");
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
setFileDiff(undefined);
|
|
1246
|
+
setDiffCursor(0);
|
|
1247
|
+
setPreviewPath(undefined);
|
|
1248
|
+
setMode("author");
|
|
1249
|
+
setAuthorView("diff");
|
|
1250
|
+
if (agentHere) {
|
|
1251
|
+
void refreshSnapshot();
|
|
1252
|
+
void loadDiffFiles();
|
|
1253
|
+
}
|
|
1254
|
+
// Workspace level: the browse-listing effect walks the place's files.
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
function toggleDiffStyle() {
|
|
1258
|
+
const next = stateRef.current.diffStyleEffective === "split" ? "unified" : "split";
|
|
1259
|
+
setDiffStyle(next);
|
|
1260
|
+
setToast(`diff style: ${next}`);
|
|
1261
|
+
}
|
|
1262
|
+
// --- Operation tracking (TUI_DESIGN §13): real work lands in the store ---
|
|
1263
|
+
/** Start a tracked operation for whitelisted commands; else undefined. */
|
|
1264
|
+
function trackOperation(name, retry) {
|
|
1265
|
+
if (!isOperationCommand(name))
|
|
1266
|
+
return undefined;
|
|
1267
|
+
const agent = stateRef.current.scope.agent;
|
|
1268
|
+
return operationStore.start(name, agent !== "—" ? `${name} · ${agent}` : name, retry);
|
|
1269
|
+
}
|
|
1270
|
+
function finishOperation(id, result) {
|
|
1271
|
+
if (id === undefined)
|
|
1272
|
+
return;
|
|
1273
|
+
const output = result.paneLines ?? (result.toast ? [result.toast] : []);
|
|
1274
|
+
if (result.error)
|
|
1275
|
+
operationStore.fail(id, result.error, output);
|
|
1276
|
+
else {
|
|
1277
|
+
operationStore.succeed(id, result.toast ?? result.paneLines?.[0], output);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function failOperation(id, error) {
|
|
1281
|
+
if (id === undefined)
|
|
1282
|
+
return;
|
|
1283
|
+
operationStore.fail(id, error instanceof Error ? error.message : String(error));
|
|
1284
|
+
}
|
|
1285
|
+
function retryOperation(op) {
|
|
1286
|
+
if (op.status !== "failed") {
|
|
1287
|
+
setToast("Only failed operations retry — re-run via ^K");
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const retry = op.retry;
|
|
1291
|
+
if (!retry) {
|
|
1292
|
+
setToast("This operation can't be retried (needs confirmation)");
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
setActivityCursor(undefined);
|
|
1296
|
+
if (retry.mode === "validate") {
|
|
1297
|
+
void runStructuredValidate();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const command = SLASH_COMMANDS.find((c) => c.name === retry.name);
|
|
1301
|
+
if (!command) {
|
|
1302
|
+
setToast(`Unknown command: ${retry.name}`);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (retry.mode === "simple") {
|
|
1306
|
+
void runCommand(command, retry.args);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
runSheetOp(command, retry.values);
|
|
1310
|
+
}
|
|
1311
|
+
function copyOperationOutput(op) {
|
|
1312
|
+
const text = [op.title, op.detail ?? "", ...op.output]
|
|
1313
|
+
.filter(Boolean)
|
|
1314
|
+
.join("\n");
|
|
1315
|
+
void copyToClipboard(text).then(() => setToast("Output copied to clipboard"), (error) => setToast(error instanceof Error ? error.message : String(error)));
|
|
1316
|
+
}
|
|
1317
|
+
async function runStructuredValidate() {
|
|
1318
|
+
setWorking(true);
|
|
1319
|
+
const opId = trackOperation("validate", { mode: "validate" });
|
|
1320
|
+
try {
|
|
1321
|
+
const ctx = stateRef.current.context;
|
|
1322
|
+
const record = await runValidationRecord(ctx, createClients(ctx).runtime);
|
|
1323
|
+
setValidation(record);
|
|
1324
|
+
if (opId !== undefined) {
|
|
1325
|
+
const errors = record.result.findings.filter((f) => f.level !== "warning").length;
|
|
1326
|
+
const lines = record.result.findings.map((f) => `${(f.level ?? "error").toUpperCase()} ${f.file ?? f.code ?? f.path ?? ""} ${f.message}`);
|
|
1327
|
+
// CI semantics: findings-with-errors is a red operation.
|
|
1328
|
+
if (record.result.valid && errors === 0) {
|
|
1329
|
+
operationStore.succeed(opId, validationSummary(record), lines);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
operationStore.fail(opId, validationSummary(record), lines);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
setMode("author");
|
|
1336
|
+
setAuthorView("validation");
|
|
1337
|
+
}
|
|
1338
|
+
catch (error) {
|
|
1339
|
+
failOperation(opId, error);
|
|
1340
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1341
|
+
}
|
|
1342
|
+
finally {
|
|
1343
|
+
setWorking(false);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
async function openVersionsView() {
|
|
1347
|
+
setWorking(true);
|
|
1348
|
+
try {
|
|
1349
|
+
const ctx = stateRef.current.context;
|
|
1350
|
+
const page = await listVersions(ctx, createClients(ctx).management);
|
|
1351
|
+
setVersions(page.results);
|
|
1352
|
+
setMode("author");
|
|
1353
|
+
setAuthorView("versions");
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1357
|
+
}
|
|
1358
|
+
finally {
|
|
1359
|
+
setWorking(false);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Author copilot turn (Phase 5): free text on the Author home becomes a
|
|
1364
|
+
* backend turn against the current checkout. The turn is a durable
|
|
1365
|
+
* operation; file truth stays with the watcher (changes card updates as
|
|
1366
|
+
* the backend saves), never with backend-reported edits.
|
|
1367
|
+
*/
|
|
1368
|
+
async function startAuthorTurn(text) {
|
|
1369
|
+
const current = stateRef.current;
|
|
1370
|
+
if (current.authorSession?.running) {
|
|
1371
|
+
// Never eat a message: queue it, visible in the transcript, sent
|
|
1372
|
+
// in order when this turn finishes.
|
|
1373
|
+
setQueuedPrompts((q) => [...q, text]);
|
|
1374
|
+
setToast("Queued — sends when this turn finishes (Esc drops it)");
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const detection = current.backendDetection;
|
|
1378
|
+
if (!detection?.available) {
|
|
1379
|
+
setToast(detection?.reason ?? `${authorBackend.label} not detected — checking…`);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const checkoutDir = agentRootOf(current.context);
|
|
1383
|
+
if (!checkoutDir) {
|
|
1384
|
+
setToast("No agent checkout — ^G to pick an agent");
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const agent = current.scope.agent;
|
|
1388
|
+
const opId = operationStore.start("author-turn", `author · ${authorBackend.label}${agent !== "—" ? ` · ${agent}` : ""}`);
|
|
1389
|
+
turnOpIdRef.current = opId;
|
|
1390
|
+
turnFeedRef.current = [`you: ${text}`];
|
|
1391
|
+
// Append the prompt to the running transcript (multi-turn session).
|
|
1392
|
+
setAuthorSession((s) => sessionPrompt(s ?? sessionCreate(authorBackend.label), text));
|
|
1393
|
+
const sessions = await loadSessions();
|
|
1394
|
+
const handle = authorBackend.startTurn({
|
|
1395
|
+
checkoutDir,
|
|
1396
|
+
text,
|
|
1397
|
+
...(sessions[checkoutDir] ? { sessionId: sessions[checkoutDir] } : {}),
|
|
1398
|
+
onEvent: (raw) => {
|
|
1399
|
+
// Stale guard: cancelled/superseded turns stop touching state.
|
|
1400
|
+
if (turnOpIdRef.current !== opId)
|
|
1401
|
+
return;
|
|
1402
|
+
// Checkout-relative paths in tool lines — shorter, wrap-proof.
|
|
1403
|
+
const event = relativizeEvent(raw, checkoutDir);
|
|
1404
|
+
setAuthorSession((s) => (s ? sessionEvent(s, event) : s));
|
|
1405
|
+
switch (event.type) {
|
|
1406
|
+
case "text":
|
|
1407
|
+
turnFeedRef.current.push(...event.text.split("\n"));
|
|
1408
|
+
break;
|
|
1409
|
+
case "action": {
|
|
1410
|
+
const label = `${event.label}${event.detail ? ` — ${event.detail}` : ""}`;
|
|
1411
|
+
turnFeedRef.current.push(`⚙ ${label}`);
|
|
1412
|
+
operationStore.progress(opId, label);
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
case "text-delta":
|
|
1416
|
+
case "thinking-delta":
|
|
1417
|
+
case "action-start":
|
|
1418
|
+
case "action-result":
|
|
1419
|
+
break; // rendered via the session; the feed keeps complete blocks
|
|
1420
|
+
case "done":
|
|
1421
|
+
turnHandleRef.current = undefined;
|
|
1422
|
+
turnOpIdRef.current = undefined;
|
|
1423
|
+
operationStore.succeed(opId, event.summary ?? "turn complete", [
|
|
1424
|
+
...turnFeedRef.current,
|
|
1425
|
+
]);
|
|
1426
|
+
if (event.sessionId)
|
|
1427
|
+
void saveSession(checkoutDir, event.sessionId);
|
|
1428
|
+
void loadAuthorSnapshot(stateRef.current.context).then(setSnapshot);
|
|
1429
|
+
break;
|
|
1430
|
+
case "error":
|
|
1431
|
+
turnHandleRef.current = undefined;
|
|
1432
|
+
turnOpIdRef.current = undefined;
|
|
1433
|
+
turnFeedRef.current.push(`✕ ${event.message}`);
|
|
1434
|
+
operationStore.fail(opId, event.message, [...turnFeedRef.current]);
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
turnHandleRef.current = handle;
|
|
1440
|
+
}
|
|
1441
|
+
/** Esc while a turn runs: SIGTERM the backend, mark everything cancelled. */
|
|
1442
|
+
function cancelAuthorTurn() {
|
|
1443
|
+
const opId = turnOpIdRef.current;
|
|
1444
|
+
turnOpIdRef.current = undefined; // stale-guard: adapter's exit event is ignored
|
|
1445
|
+
turnHandleRef.current?.cancel();
|
|
1446
|
+
turnHandleRef.current = undefined;
|
|
1447
|
+
setAuthorSession((s) => (s ? sessionCancel(s) : s));
|
|
1448
|
+
if (opId !== undefined) {
|
|
1449
|
+
operationStore.fail(opId, "Cancelled", [...turnFeedRef.current]);
|
|
1450
|
+
}
|
|
1451
|
+
// Cancelling bails on the whole intent — queued follow-ups go with it.
|
|
1452
|
+
const queued = stateRef.current.queuedPrompts.length;
|
|
1453
|
+
setQueuedPrompts([]);
|
|
1454
|
+
setToast(queued > 0
|
|
1455
|
+
? `Turn cancelled — ${queued} queued message(s) dropped`
|
|
1456
|
+
: "Turn cancelled");
|
|
1457
|
+
}
|
|
98
1458
|
function ensureAuth(command) {
|
|
99
|
-
if (!command.requiresAuth || scope.loggedIn)
|
|
1459
|
+
if (!command.requiresAuth || stateRef.current.scope.loggedIn)
|
|
100
1460
|
return true;
|
|
101
|
-
|
|
102
|
-
|
|
1461
|
+
const login = SLASH_COMMANDS.find((c) => c.name === "login");
|
|
1462
|
+
if (login?.fields) {
|
|
1463
|
+
setSheet(createSheet(login.name, login.fields));
|
|
1464
|
+
setSheetCommand(login);
|
|
1465
|
+
}
|
|
1466
|
+
setToast("Log in first");
|
|
103
1467
|
return false;
|
|
104
1468
|
}
|
|
105
|
-
async function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
1469
|
+
async function openPick(command, kind, sheetModel, sheetField, contextOverride) {
|
|
1470
|
+
setWorking(true);
|
|
1471
|
+
try {
|
|
1472
|
+
const items = await loadPickerCatalog(kind, contextOverride ?? stateRef.current.context);
|
|
1473
|
+
if (items.length === 0) {
|
|
1474
|
+
setToast(`No ${kind} options`);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (items.length === 1 && !sheetField && command.name !== "place-onboard") {
|
|
1478
|
+
await finishPick(command, items[0].id, sheetModel, sheetField);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
setSheet(undefined);
|
|
1482
|
+
setPaletteCursor(undefined);
|
|
1483
|
+
setSwitcherCursor(undefined);
|
|
1484
|
+
setPicker({
|
|
1485
|
+
command,
|
|
1486
|
+
items,
|
|
1487
|
+
cursor: 0,
|
|
1488
|
+
...(sheetField ? { sheetField } : {}),
|
|
1489
|
+
...(sheetModel ? { sheet: sheetModel } : {}),
|
|
1490
|
+
});
|
|
1491
|
+
setInput("");
|
|
1492
|
+
}
|
|
1493
|
+
catch (error) {
|
|
1494
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1495
|
+
}
|
|
1496
|
+
finally {
|
|
1497
|
+
setWorking(false);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
async function finishPick(command, id, sheetModel, sheetField) {
|
|
1501
|
+
setPicker(undefined);
|
|
1502
|
+
if (sheetModel && sheetField) {
|
|
1503
|
+
const next = {
|
|
1504
|
+
...sheetModel,
|
|
1505
|
+
values: { ...sheetModel.values, [sheetField]: id },
|
|
1506
|
+
};
|
|
1507
|
+
setSheet(next);
|
|
1508
|
+
setSheetCommand(command);
|
|
1509
|
+
setToast(`Selected ${id}`);
|
|
109
1510
|
return;
|
|
110
1511
|
}
|
|
111
|
-
if (
|
|
1512
|
+
if (command.destructive &&
|
|
1513
|
+
(command.name === "version-publish" || command.name === "version-archive")) {
|
|
1514
|
+
setConfirm(createConfirm(command.name, "yes", command.name, { id }));
|
|
112
1515
|
return;
|
|
1516
|
+
}
|
|
1517
|
+
setWorking(true);
|
|
1518
|
+
try {
|
|
1519
|
+
await applyResult(await applyPick(command.name, id, stateRef.current.context));
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1523
|
+
}
|
|
1524
|
+
finally {
|
|
1525
|
+
setWorking(false);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
async function runCommand(command, args) {
|
|
113
1529
|
if (command.name === "help") {
|
|
114
|
-
|
|
1530
|
+
setPaletteCursor(0);
|
|
1531
|
+
setInput("");
|
|
115
1532
|
return;
|
|
116
1533
|
}
|
|
117
1534
|
if (command.name === "quit") {
|
|
118
1535
|
onQuit(0);
|
|
119
1536
|
return;
|
|
120
1537
|
}
|
|
1538
|
+
if (command.name === "activity") {
|
|
1539
|
+
setActivityCursor((c) => (c === undefined ? 0 : undefined));
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
// Structured diff view is local-first; works logged out (content diff
|
|
1543
|
+
// fetches will surface their own auth errors).
|
|
1544
|
+
if (command.name === "diff" && !args.trim()) {
|
|
1545
|
+
openDiffView();
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (command.name === "diff-style") {
|
|
1549
|
+
const current = stateRef.current;
|
|
1550
|
+
const inDiffView = current.mode === "author" && current.authorView === "diff";
|
|
1551
|
+
if (inDiffView || openDiffView())
|
|
1552
|
+
toggleDiffStyle();
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (!ensureAuth(command))
|
|
1556
|
+
return;
|
|
1557
|
+
if (command.name === "validate") {
|
|
1558
|
+
await runStructuredValidate();
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (command.name === "versions") {
|
|
1562
|
+
await openVersionsView();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (command.name === "trace") {
|
|
1566
|
+
setTrace((v) => {
|
|
1567
|
+
setToast(`trace ${v ? "off" : "on"}`);
|
|
1568
|
+
return !v;
|
|
1569
|
+
});
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (command.name === "local") {
|
|
1573
|
+
setLocalMode((v) => {
|
|
1574
|
+
setToast(v
|
|
1575
|
+
? "local bundle off — replies run the published version"
|
|
1576
|
+
: "local bundle on — replies run this checkout");
|
|
1577
|
+
return !v;
|
|
1578
|
+
});
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
// Turn diagnostics land in the inspector (^T/^D reach here too).
|
|
1582
|
+
if (command.name === "turn-trace" || command.name === "turn-debug") {
|
|
1583
|
+
setMode("talk");
|
|
1584
|
+
await inspectTurn(command.name === "turn-trace" ? "trace" : "debug");
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const confirmSpec = destructiveSpec(command.name, stateRef.current.context);
|
|
1588
|
+
if (confirmSpec && !command.pick) {
|
|
1589
|
+
setConfirm(createConfirm(confirmSpec.title, confirmSpec.phrase, confirmSpec.action, confirmSpec.args));
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (command.pick) {
|
|
1593
|
+
if (args.trim()) {
|
|
1594
|
+
setWorking(true);
|
|
1595
|
+
try {
|
|
1596
|
+
await applyResult(await applyPick(command.name, args.trim(), stateRef.current.context));
|
|
1597
|
+
}
|
|
1598
|
+
catch (error) {
|
|
1599
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1600
|
+
}
|
|
1601
|
+
finally {
|
|
1602
|
+
setWorking(false);
|
|
1603
|
+
}
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
await openPick(command, command.pick);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
121
1609
|
if (command.fields?.length) {
|
|
122
|
-
let model = createSheet(command.name, command.fields);
|
|
1610
|
+
let model = createSheet(command.name, command.fields, sheetInfoFor(command.name));
|
|
123
1611
|
if (args)
|
|
124
1612
|
model = setFocusedValue(model, args);
|
|
125
1613
|
setSheet(model);
|
|
126
1614
|
setSheetCommand(command);
|
|
1615
|
+
const focused = visibleFields(model)[model.focused];
|
|
1616
|
+
if (focused?.control === "picker" && focused.picker) {
|
|
1617
|
+
await openPick(command, focused.picker, model, focused.name);
|
|
1618
|
+
}
|
|
127
1619
|
return;
|
|
128
1620
|
}
|
|
129
1621
|
setWorking(true);
|
|
1622
|
+
const opId = trackOperation(command.name, {
|
|
1623
|
+
mode: "simple",
|
|
1624
|
+
name: command.name,
|
|
1625
|
+
args,
|
|
1626
|
+
});
|
|
130
1627
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const result = await validateLocalBundle(context, createClients(context).runtime);
|
|
138
|
-
if (result.valid) {
|
|
139
|
-
setToast(result.program?.id
|
|
140
|
-
? `Valid — ${result.program.id}@${result.program.version ?? "?"}`
|
|
141
|
-
: "Program is valid");
|
|
142
|
-
}
|
|
143
|
-
else {
|
|
144
|
-
const errors = result.findings.filter((f) => f.level !== "warning");
|
|
145
|
-
const first = errors[0]?.message ?? "validation failed";
|
|
146
|
-
setToast(`Invalid: ${first}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
else if (command.name === "diff") {
|
|
150
|
-
setTab("bundle");
|
|
151
|
-
const remote = args.trim().toLowerCase() === "remote";
|
|
152
|
-
const diff = remote
|
|
153
|
-
? await remoteDiff(context, createClients(context).management)
|
|
154
|
-
: await localDiff(context);
|
|
155
|
-
setToast(`diff vs ${remote ? "remote" : "baseline"} · ${diffSummary(diff)}`);
|
|
156
|
-
}
|
|
157
|
-
else if (command.name === "trace") {
|
|
158
|
-
setTrace((v) => {
|
|
159
|
-
setToast(`trace ${v ? "off" : "on"}`);
|
|
160
|
-
return !v;
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
else if (command.name === "local") {
|
|
164
|
-
setLocalMode((v) => {
|
|
165
|
-
setToast(`local mode ${v ? "off" : "on"}`);
|
|
166
|
-
return !v;
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
else if (command.name === "new") {
|
|
170
|
-
setChat([]);
|
|
171
|
-
setToast("New thread");
|
|
172
|
-
}
|
|
173
|
-
else if (command.name === "logout") {
|
|
174
|
-
setToast("Use apollo logout in the CLI (confirm modal coming soon)");
|
|
175
|
-
}
|
|
176
|
-
else if (command.name === "upgrade") {
|
|
177
|
-
setToast("Run apollo upgrade from the CLI");
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
setToast(`/${command.name} queued`);
|
|
181
|
-
}
|
|
1628
|
+
const result = await runSimpleCommand(command.name,
|
|
1629
|
+
// Chat commands (`new` creates threads) run under the talk target so
|
|
1630
|
+
// a pinned tag or forced live applies to them too.
|
|
1631
|
+
command.group === "chat" ? talkContext() : stateRef.current.context, args);
|
|
1632
|
+
finishOperation(opId, result);
|
|
1633
|
+
await applyResult(result);
|
|
182
1634
|
}
|
|
183
1635
|
catch (error) {
|
|
1636
|
+
failOperation(opId, error);
|
|
184
1637
|
setToast(error instanceof Error ? error.message : String(error));
|
|
185
1638
|
}
|
|
186
1639
|
finally {
|
|
187
1640
|
setWorking(false);
|
|
188
1641
|
}
|
|
189
1642
|
}
|
|
1643
|
+
/** Sheet-backed action run — used by sheet submit and drawer retry. */
|
|
1644
|
+
function runSheetOp(command, values) {
|
|
1645
|
+
void (async () => {
|
|
1646
|
+
setWorking(true);
|
|
1647
|
+
const opId = trackOperation(command.name, {
|
|
1648
|
+
mode: "sheet",
|
|
1649
|
+
name: command.name,
|
|
1650
|
+
values,
|
|
1651
|
+
});
|
|
1652
|
+
try {
|
|
1653
|
+
const result = await runSheetAction(command.name, values, stateRef.current.context);
|
|
1654
|
+
finishOperation(opId, result);
|
|
1655
|
+
await applyResult(result);
|
|
1656
|
+
}
|
|
1657
|
+
catch (err) {
|
|
1658
|
+
failOperation(opId, err);
|
|
1659
|
+
setToast(err instanceof Error ? err.message : String(err));
|
|
1660
|
+
}
|
|
1661
|
+
finally {
|
|
1662
|
+
setWorking(false);
|
|
1663
|
+
}
|
|
1664
|
+
})();
|
|
1665
|
+
}
|
|
190
1666
|
function submitSheet(model, command) {
|
|
191
1667
|
const error = validateSheet(model);
|
|
192
1668
|
if (error) {
|
|
@@ -203,86 +1679,48 @@ function App({ context, initialScope, glyph, onQuit }) {
|
|
|
203
1679
|
}
|
|
204
1680
|
return;
|
|
205
1681
|
}
|
|
206
|
-
|
|
207
|
-
setTab("scope");
|
|
208
|
-
setToast("Browser login isn't in the TUI yet — run apollo login from the CLI");
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
void executeSheet(command, model.values);
|
|
1682
|
+
runSheetOp(command, model.values);
|
|
212
1683
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return typeof value === "string" ? value.trim() : "";
|
|
218
|
-
};
|
|
219
|
-
setWorking(true);
|
|
220
|
-
try {
|
|
221
|
-
const clients = createClients(context);
|
|
222
|
-
if (command.name === "pull") {
|
|
223
|
-
setTab("bundle");
|
|
224
|
-
const tag = text("tag");
|
|
225
|
-
const dir = text("dir");
|
|
226
|
-
const result = await pullBundle(context, clients.management, {
|
|
227
|
-
// "latest" is the sheet default; omitting the tag pulls the
|
|
228
|
-
// selected/live version, matching the CLI's bare `apollo pull`.
|
|
229
|
-
...(tag && tag !== "latest" ? { tag } : {}),
|
|
230
|
-
...(dir ? { destination: dir } : {}),
|
|
231
|
-
force: false,
|
|
232
|
-
});
|
|
233
|
-
const removed = result.removed.length ? ` · removed ${result.removed.length}` : "";
|
|
234
|
-
setToast(`Pulled ${Object.keys(result.files).length} file(s)${removed}`);
|
|
235
|
-
}
|
|
236
|
-
else if (command.name === "push") {
|
|
237
|
-
setTab("bundle");
|
|
238
|
-
const message = text("message");
|
|
239
|
-
const dryRun = values.dryRun === true;
|
|
240
|
-
const result = await pushBundle(context, clients.management, clients.runtime, {
|
|
241
|
-
...(message ? { commitMessage: message } : {}),
|
|
242
|
-
dryRun,
|
|
243
|
-
});
|
|
244
|
-
setToast(result.push
|
|
245
|
-
? `Pushed ${result.push.new_version_tag} · ${diffSummary(result.diff)}`
|
|
246
|
-
: `Dry run · ${diffSummary(result.diff)}`);
|
|
247
|
-
}
|
|
248
|
-
else if (command.name === "provision") {
|
|
249
|
-
setTab("mockdb");
|
|
250
|
-
const provisioned = await provisionMockDb(context);
|
|
251
|
-
let note = `Provisioned mock DB for ${provisioned.agentId ?? "agent"}`;
|
|
252
|
-
if (values.wire === true) {
|
|
253
|
-
const wired = await wireMockDbConnection(context);
|
|
254
|
-
note += ` · wired ${wired.connectionId}`;
|
|
255
|
-
}
|
|
256
|
-
setToast(note);
|
|
257
|
-
}
|
|
258
|
-
else if (command.name === "thread") {
|
|
259
|
-
setChat([]);
|
|
260
|
-
setToast(`Thread ${text("thread") || "new"} (per-thread history coming soon)`);
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
setToast(`/${command.name} isn't wired in the TUI yet — use apollo ${command.name}`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
catch (error) {
|
|
267
|
-
setToast(error instanceof Error ? error.message : String(error));
|
|
268
|
-
}
|
|
269
|
-
finally {
|
|
270
|
-
setWorking(false);
|
|
1684
|
+
async function handleSheetLine(model, command, line) {
|
|
1685
|
+
if (line === "+" || line.toLowerCase() === "advanced") {
|
|
1686
|
+
setSheet({ ...model, advancedOpen: !model.advancedOpen });
|
|
1687
|
+
return;
|
|
271
1688
|
}
|
|
272
|
-
}
|
|
273
|
-
function handleSheetLine(model, command, line) {
|
|
274
1689
|
const fields = visibleFields(model);
|
|
275
1690
|
const focused = fields[model.focused];
|
|
276
1691
|
if (line === "") {
|
|
277
|
-
// Empty entry advances; on the last field it submits.
|
|
278
1692
|
if (model.focused >= fields.length - 1) {
|
|
279
1693
|
submitSheet(model, command);
|
|
280
1694
|
}
|
|
281
1695
|
else {
|
|
282
|
-
|
|
1696
|
+
const next = moveFocus(model, 1);
|
|
1697
|
+
setSheet(next);
|
|
1698
|
+
const nextField = visibleFields(next)[next.focused];
|
|
1699
|
+
if (nextField?.control === "picker" && nextField.picker) {
|
|
1700
|
+
await openPick(command, nextField.picker, next, nextField.name);
|
|
1701
|
+
}
|
|
283
1702
|
}
|
|
284
1703
|
return;
|
|
285
1704
|
}
|
|
1705
|
+
if (focused?.control === "picker" && focused.picker) {
|
|
1706
|
+
await openPick(command, focused.picker, model, focused.name);
|
|
1707
|
+
// Also try resolving against a fresh catalog if user typed a filter as the line
|
|
1708
|
+
setWorking(true);
|
|
1709
|
+
try {
|
|
1710
|
+
const items = await loadPickerCatalog(focused.picker, stateRef.current.context);
|
|
1711
|
+
const hit = resolvePickerAnswer(filterPickerItems(items, line), line);
|
|
1712
|
+
if (hit) {
|
|
1713
|
+
await finishPick(command, hit.id, model, focused.name);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
catch {
|
|
1718
|
+
/* fall through */
|
|
1719
|
+
}
|
|
1720
|
+
finally {
|
|
1721
|
+
setWorking(false);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
286
1724
|
let next = model;
|
|
287
1725
|
if (focused?.control === "toggle") {
|
|
288
1726
|
const truthy = ["y", "yes", "true", "on", "x"].includes(line.toLowerCase());
|
|
@@ -295,134 +1733,528 @@ function App({ context, initialScope, glyph, onQuit }) {
|
|
|
295
1733
|
submitSheet(next, command);
|
|
296
1734
|
}
|
|
297
1735
|
else {
|
|
298
|
-
|
|
1736
|
+
const moved = moveFocus(next, 1);
|
|
1737
|
+
setSheet(moved);
|
|
1738
|
+
const nextField = visibleFields(moved)[moved.focused];
|
|
1739
|
+
if (nextField?.control === "picker" && nextField.picker) {
|
|
1740
|
+
await openPick(command, nextField.picker, moved, nextField.name);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
function clearComposer() {
|
|
1745
|
+
composerRef.current?.setText("");
|
|
1746
|
+
setComposer("");
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* The context sends actually run under: the session talk target overrides
|
|
1750
|
+
* the bound tag ("latest" forces live resolution by dropping the pin).
|
|
1751
|
+
*/
|
|
1752
|
+
function talkContext() {
|
|
1753
|
+
const { context: ctx, talkTag: tag } = stateRef.current;
|
|
1754
|
+
if (!tag)
|
|
1755
|
+
return ctx;
|
|
1756
|
+
if (tag === "latest") {
|
|
1757
|
+
// No pin → version resolution falls to the live version.
|
|
1758
|
+
return { ...ctx, versionTag: undefined };
|
|
299
1759
|
}
|
|
1760
|
+
return { ...ctx, versionTag: tag };
|
|
300
1761
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1762
|
+
/** Client-side cancel: the API has no abort, so the reply is discarded. */
|
|
1763
|
+
function cancelTalkSend() {
|
|
1764
|
+
sendSeqRef.current += 1;
|
|
1765
|
+
setChat((c) => cancelSend(c, Date.now()));
|
|
1766
|
+
setWorking(false);
|
|
1767
|
+
setToast("Cancelled — the reply is discarded when it arrives");
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Honest pending: optimistic user turn + a waiting placeholder that shows
|
|
1771
|
+
* real elapsed time — the messaging API is request/response, so there is
|
|
1772
|
+
* no token streaming to fake (TUI_DESIGN §9.5).
|
|
1773
|
+
*/
|
|
1774
|
+
async function sendTalkMessage(text) {
|
|
1775
|
+
const current = stateRef.current;
|
|
1776
|
+
const reason = composerDisabledReason(current.scope, current.localMode, current.scope.agentPath);
|
|
1777
|
+
if (reason) {
|
|
1778
|
+
setToast(`Can’t send — ${reason}`);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
if (current.talkPending) {
|
|
1782
|
+
setToast("Waiting for the reply — Esc cancels it first");
|
|
305
1783
|
return;
|
|
306
1784
|
}
|
|
1785
|
+
const seq = ++sendSeqRef.current;
|
|
1786
|
+
const startedAt = Date.now();
|
|
1787
|
+
setChat((c) => beginSend(c, text, startedAt));
|
|
1788
|
+
setTalkFocus("chat");
|
|
307
1789
|
setWorking(true);
|
|
308
1790
|
try {
|
|
309
|
-
|
|
310
|
-
const
|
|
1791
|
+
// Session talk target applied: pinned tag or forced live.
|
|
1792
|
+
const ctx = talkContext();
|
|
1793
|
+
const clients = createClients(ctx);
|
|
1794
|
+
const local = Boolean(current.localMode && ctx.agentRoot);
|
|
1795
|
+
// The runtime needs an explicit version id (bound tag, else the live
|
|
1796
|
+
// version) — it does not resolve tags itself. Local mode skips this:
|
|
1797
|
+
// the inline bundle carries the program.
|
|
1798
|
+
const versionId = local
|
|
1799
|
+
? undefined
|
|
1800
|
+
: await resolveSendVersion(ctx, clients.management);
|
|
1801
|
+
const result = await sendMessage(ctx, clients.runtime, {
|
|
311
1802
|
text,
|
|
312
|
-
...(
|
|
313
|
-
...(
|
|
314
|
-
|
|
1803
|
+
...(versionId ? { versionId } : {}),
|
|
1804
|
+
...(current.threadId ? { threadId: current.threadId } : {}),
|
|
1805
|
+
includeTrace: true,
|
|
1806
|
+
...(local && ctx.agentRoot ? { localDir: ctx.agentRoot } : {}),
|
|
1807
|
+
variables: current.vars,
|
|
315
1808
|
});
|
|
316
|
-
|
|
317
|
-
|
|
1809
|
+
if (seq !== sendSeqRef.current)
|
|
1810
|
+
return; // cancelled or superseded
|
|
1811
|
+
if (result.thread_id) {
|
|
1812
|
+
setThreadId(result.thread_id);
|
|
1813
|
+
if (!current.threads?.some((t) => t.id === result.thread_id)) {
|
|
1814
|
+
setThreads(undefined); // pane learns the new thread lazily
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
const message = result.message;
|
|
1818
|
+
const tokens = message.input_tokens !== undefined || message.output_tokens !== undefined
|
|
1819
|
+
? {
|
|
1820
|
+
...(message.input_tokens !== undefined
|
|
1821
|
+
? { input: message.input_tokens }
|
|
1822
|
+
: {}),
|
|
1823
|
+
...(message.output_tokens !== undefined
|
|
1824
|
+
? { output: message.output_tokens }
|
|
1825
|
+
: {}),
|
|
1826
|
+
}
|
|
1827
|
+
: undefined;
|
|
1828
|
+
setChat((c) => settleSend(c, {
|
|
1829
|
+
role: "agent",
|
|
1830
|
+
text: message.text ?? JSON.stringify(result),
|
|
1831
|
+
messageId: message.id,
|
|
1832
|
+
interactionId: message.id,
|
|
1833
|
+
...(message.trace_info ? { trace: message.trace_info } : {}),
|
|
1834
|
+
at: Date.now(),
|
|
1835
|
+
durationMs: Date.now() - startedAt,
|
|
1836
|
+
...(tokens ? { tokens } : {}),
|
|
1837
|
+
}));
|
|
318
1838
|
}
|
|
319
1839
|
catch (error) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1840
|
+
if (seq !== sendSeqRef.current)
|
|
1841
|
+
return;
|
|
1842
|
+
setChat((c) => settleSend(c, {
|
|
1843
|
+
role: "system",
|
|
1844
|
+
text: error instanceof Error ? error.message : String(error),
|
|
1845
|
+
at: Date.now(),
|
|
1846
|
+
}));
|
|
1847
|
+
// Failed sends keep the message for a retry (unless a new draft began).
|
|
1848
|
+
if (!stateRef.current.composer) {
|
|
1849
|
+
composerRef.current?.setText(text);
|
|
1850
|
+
setComposer(text);
|
|
1851
|
+
}
|
|
1852
|
+
setToast("Send failed — message kept in the composer");
|
|
1853
|
+
}
|
|
1854
|
+
finally {
|
|
1855
|
+
if (seq === sendSeqRef.current)
|
|
1856
|
+
setWorking(false);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* ^T/^D — diagnostics for the selected turn, attached where they belong:
|
|
1861
|
+
* the inspector pane when it fits, the inline overlay otherwise. A
|
|
1862
|
+
* non-agent selection falls back to the newest reply (and moves to it).
|
|
1863
|
+
*/
|
|
1864
|
+
async function inspectTurn(kind) {
|
|
1865
|
+
const current = stateRef.current;
|
|
1866
|
+
const cursor = clampPickerCursor(current.talkCursor, current.chat.length);
|
|
1867
|
+
let target = cursor;
|
|
1868
|
+
let ordinal = agentOrdinal(current.chat, cursor);
|
|
1869
|
+
if (ordinal === undefined) {
|
|
1870
|
+
target = lastAgentTurnIndex(current.chat);
|
|
1871
|
+
if (target < 0) {
|
|
1872
|
+
setToast("No agent replies yet — send a message first");
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
ordinal = agentOrdinal(current.chat, target);
|
|
1876
|
+
}
|
|
1877
|
+
setWorking(true);
|
|
1878
|
+
try {
|
|
1879
|
+
const result = await chatOverlay(kind, current.chat, current.threadId, current.context, ordinal !== undefined ? String(ordinal) : undefined);
|
|
1880
|
+
if (result.error) {
|
|
1881
|
+
setToast(result.error);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
if (current.talkLayout.inspector) {
|
|
1885
|
+
setMode("talk");
|
|
1886
|
+
if (target !== cursor)
|
|
1887
|
+
setTalkCursor(target);
|
|
1888
|
+
setInspectorFetch({ turn: target, kind, lines: result.paneLines ?? [] });
|
|
1889
|
+
setToast(`${kind} · reply #${ordinal ?? "?"} → inspector`);
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
await applyResult(result); // narrow terminals: inline overlay
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
catch (error) {
|
|
1896
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
324
1897
|
}
|
|
325
1898
|
finally {
|
|
326
1899
|
setWorking(false);
|
|
327
1900
|
}
|
|
328
1901
|
}
|
|
1902
|
+
/** Enter in the composer: threads focus opens; otherwise send the draft. */
|
|
1903
|
+
function handleComposerSend() {
|
|
1904
|
+
const current = stateRef.current;
|
|
1905
|
+
if (current.talkFocus === "threads") {
|
|
1906
|
+
const count = threadsRowCount(current.threads);
|
|
1907
|
+
const row = clampPickerCursor(current.threadCursor, count);
|
|
1908
|
+
if (row === NEW_THREAD_ROW) {
|
|
1909
|
+
const cmd = SLASH_COMMANDS.find((c) => c.name === "new");
|
|
1910
|
+
if (cmd)
|
|
1911
|
+
void runCommand(cmd, "");
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
const picked = current.threads?.[row - 1];
|
|
1915
|
+
if (picked)
|
|
1916
|
+
void openThread(picked.id);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
const text = (composerRef.current?.plainText ?? "").trim();
|
|
1920
|
+
if (!text)
|
|
1921
|
+
return;
|
|
1922
|
+
clearComposer();
|
|
1923
|
+
void sendTalkMessage(text);
|
|
1924
|
+
}
|
|
1925
|
+
async function openThread(id) {
|
|
1926
|
+
setWorking(true);
|
|
1927
|
+
try {
|
|
1928
|
+
await applyResult(await applyPick("thread", id, stateRef.current.context));
|
|
1929
|
+
}
|
|
1930
|
+
catch (error) {
|
|
1931
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1932
|
+
}
|
|
1933
|
+
finally {
|
|
1934
|
+
setWorking(false);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
function runLine(line) {
|
|
1938
|
+
const named = line.slice(1);
|
|
1939
|
+
const { command, args } = resolveCommand(line);
|
|
1940
|
+
if (command) {
|
|
1941
|
+
void runCommand(command, args);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const aliasName = named.split(/\s+/)[0] ?? "";
|
|
1945
|
+
if (OLD_NAV_NAMES.has(aliasName.toLowerCase())) {
|
|
1946
|
+
setToast("Navigation: Tab or ^1 Author / ^2 Talk");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const action = actionByAlias(REGISTRY, aliasName);
|
|
1950
|
+
if (action) {
|
|
1951
|
+
const rest = named.slice(aliasName.length).trim();
|
|
1952
|
+
void runCommand(action.command, rest);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
setToast("Unknown command — ^K to browse actions");
|
|
1956
|
+
}
|
|
329
1957
|
function handleSubmit(raw) {
|
|
330
1958
|
const line = raw.trim();
|
|
331
1959
|
setInput("");
|
|
332
1960
|
setToast(undefined);
|
|
333
1961
|
const current = stateRef.current;
|
|
1962
|
+
if (current.confirm) {
|
|
1963
|
+
const checked = checkConfirm(current.confirm, line);
|
|
1964
|
+
if (checked === "ok") {
|
|
1965
|
+
const spec = current.confirm;
|
|
1966
|
+
setConfirm(undefined);
|
|
1967
|
+
void (async () => {
|
|
1968
|
+
setWorking(true);
|
|
1969
|
+
// Destructive ops are tracked but never offered a retry — a rerun
|
|
1970
|
+
// must go back through the typed confirmation.
|
|
1971
|
+
const opId = trackOperation(spec.action);
|
|
1972
|
+
try {
|
|
1973
|
+
const result = await runDestructive(spec.action, current.context, spec.args ?? {});
|
|
1974
|
+
finishOperation(opId, result);
|
|
1975
|
+
await applyResult(result);
|
|
1976
|
+
}
|
|
1977
|
+
catch (error) {
|
|
1978
|
+
failOperation(opId, error);
|
|
1979
|
+
setToast(error instanceof Error ? error.message : String(error));
|
|
1980
|
+
}
|
|
1981
|
+
finally {
|
|
1982
|
+
setWorking(false);
|
|
1983
|
+
}
|
|
1984
|
+
})();
|
|
1985
|
+
}
|
|
1986
|
+
else {
|
|
1987
|
+
setConfirm(checked);
|
|
1988
|
+
}
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (current.picker) {
|
|
1992
|
+
const visible = filterPickerItems(current.picker.items, effectivePickerFilter(line));
|
|
1993
|
+
if (visible.length === 0) {
|
|
1994
|
+
setToast(line ? `No match for "${line}"` : "No options");
|
|
1995
|
+
setInput(line);
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
// Empty Enter → highlighted row. Digits → index. Text → unique match only.
|
|
1999
|
+
const trimmed = line.trim();
|
|
2000
|
+
let hit;
|
|
2001
|
+
if (!trimmed) {
|
|
2002
|
+
hit = visible[clampPickerCursor(current.picker.cursor, visible.length)];
|
|
2003
|
+
}
|
|
2004
|
+
else if (/^\d+$/.test(trimmed)) {
|
|
2005
|
+
hit = resolvePickerAnswer(visible, trimmed);
|
|
2006
|
+
}
|
|
2007
|
+
else {
|
|
2008
|
+
hit = resolvePickerAnswer(visible, trimmed);
|
|
2009
|
+
if (!hit && visible.length > 1) {
|
|
2010
|
+
setToast("↑↓ to choose, Enter to select (or narrow the filter)");
|
|
2011
|
+
setInput(line);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (!hit) {
|
|
2016
|
+
setToast(line ? `No match for "${line}"` : "↑↓ to choose, Enter to select");
|
|
2017
|
+
setInput(line);
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
void finishPick(current.picker.command, hit.id, current.picker.sheet, current.picker.sheetField);
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
334
2023
|
if (current.sheet && current.sheetCommand) {
|
|
335
|
-
handleSheetLine(current.sheet, current.sheetCommand, line);
|
|
2024
|
+
void handleSheetLine(current.sheet, current.sheetCommand, line);
|
|
336
2025
|
return;
|
|
337
2026
|
}
|
|
338
|
-
if (
|
|
339
|
-
const
|
|
340
|
-
if (
|
|
341
|
-
setToast("
|
|
342
|
-
setInput("/");
|
|
2027
|
+
if (current.paletteCursor !== undefined) {
|
|
2028
|
+
const matches = searchActions(REGISTRY, line, current.mode);
|
|
2029
|
+
if (matches.length === 0) {
|
|
2030
|
+
setToast("No matching actions");
|
|
343
2031
|
return;
|
|
344
2032
|
}
|
|
345
|
-
|
|
2033
|
+
const highlight = clampPickerCursor(current.paletteCursor, matches.length);
|
|
2034
|
+
const action = matches[highlight];
|
|
2035
|
+
const reason = action.disabledReason?.(current.context);
|
|
2036
|
+
setPaletteCursor(undefined);
|
|
2037
|
+
if (reason && !action.command.requiresAuth) {
|
|
2038
|
+
// Auth gaps route to login via ensureAuth; other gaps just explain.
|
|
2039
|
+
setToast(`${action.title} — ${reason}`);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
void runCommand(action.command, "");
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
if (current.switcherCursor !== undefined) {
|
|
2046
|
+
const row = CONTEXT_ROWS[clampPickerCursor(current.switcherCursor, CONTEXT_ROWS.length)];
|
|
2047
|
+
setSwitcherCursor(undefined);
|
|
2048
|
+
if (!row)
|
|
2049
|
+
return;
|
|
2050
|
+
const command = SLASH_COMMANDS.find((c) => c.name === row.id);
|
|
2051
|
+
if (command)
|
|
2052
|
+
void runCommand(command, "");
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
// Activity drawer: Enter retries the highlighted failed operation.
|
|
2056
|
+
if (current.activityCursor !== undefined) {
|
|
2057
|
+
const op = current.orderedOps[clampPickerCursor(current.activityCursor, current.orderedOps.length)];
|
|
2058
|
+
if (op && line === "") {
|
|
2059
|
+
if (op.status === "failed" && op.retry)
|
|
2060
|
+
retryOperation(op);
|
|
2061
|
+
else
|
|
2062
|
+
setToast("r retry (failed) · c copy output · Esc close");
|
|
2063
|
+
}
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (line.startsWith("/")) {
|
|
2067
|
+
runLine(line);
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
// Enter in the file tree: toggle a dir, or expand a file full-width.
|
|
2071
|
+
if (line === "" &&
|
|
2072
|
+
current.mode === "author" &&
|
|
2073
|
+
current.authorView === "diff" &&
|
|
2074
|
+
!current.fileDiff) {
|
|
2075
|
+
const rows = current.diffRows;
|
|
2076
|
+
const row = rows[clampPickerCursor(current.diffCursor, rows.length)];
|
|
2077
|
+
if (row?.kind === "dir") {
|
|
2078
|
+
setDiffCollapsed((prev) => {
|
|
2079
|
+
const next = new Set(prev);
|
|
2080
|
+
if (next.has(row.path))
|
|
2081
|
+
next.delete(row.path);
|
|
2082
|
+
else
|
|
2083
|
+
next.add(row.path);
|
|
2084
|
+
return next;
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
else if (row) {
|
|
2088
|
+
setPreviewPath(row.path);
|
|
2089
|
+
setFileDiff({ path: row.path });
|
|
2090
|
+
}
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
// Enter on a session unit: expand/collapse its body (tool input/result).
|
|
2094
|
+
if (line === "" &&
|
|
2095
|
+
current.mode === "author" &&
|
|
2096
|
+
current.authorView === "home" &&
|
|
2097
|
+
current.authorSession) {
|
|
2098
|
+
const units = current.authorSession.units;
|
|
2099
|
+
const unit = units[clampPickerCursor(current.sessionCursor, units.length)];
|
|
2100
|
+
if (unit &&
|
|
2101
|
+
unit.body.length > 0 &&
|
|
2102
|
+
(unit.kind === "action" || unit.kind === "thinking")) {
|
|
2103
|
+
setSessionExpanded((prev) => {
|
|
2104
|
+
const next = new Set(prev);
|
|
2105
|
+
if (next.has(unit.id))
|
|
2106
|
+
next.delete(unit.id);
|
|
2107
|
+
else
|
|
2108
|
+
next.add(unit.id);
|
|
2109
|
+
return next;
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
346
2112
|
return;
|
|
347
2113
|
}
|
|
348
2114
|
if (line === "")
|
|
349
2115
|
return;
|
|
350
|
-
|
|
351
|
-
|
|
2116
|
+
// `s` toggles the diff style (split ↔ unified) while in the diff view.
|
|
2117
|
+
if (current.mode === "author" &&
|
|
2118
|
+
current.authorView === "diff" &&
|
|
2119
|
+
line.toLowerCase() === "s") {
|
|
2120
|
+
toggleDiffStyle();
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
// Talk mode never reaches here without an overlay — the composer owns
|
|
2124
|
+
// sending, and ^T/^D/^N replaced the old typed t/d lines.
|
|
2125
|
+
// Author mode: single-letter quick actions; free text on the home
|
|
2126
|
+
// screen is a copilot turn (Phase 5); elsewhere it points at ^K.
|
|
2127
|
+
const quick = AUTHOR_QUICK[line.toLowerCase()];
|
|
2128
|
+
if (quick) {
|
|
2129
|
+
const command = SLASH_COMMANDS.find((c) => c.name === quick);
|
|
2130
|
+
if (command)
|
|
2131
|
+
void runCommand(command, "");
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (line === "?" || line.toLowerCase() === "h") {
|
|
2135
|
+
setPaletteCursor(0);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
if (current.authorView === "home" &&
|
|
2139
|
+
current.scope.loggedIn &&
|
|
2140
|
+
current.scope.agent !== "—") {
|
|
2141
|
+
void startAuthorTurn(line);
|
|
352
2142
|
return;
|
|
353
2143
|
}
|
|
354
|
-
setToast("
|
|
2144
|
+
setToast("^K actions · v/d/p/l/r/o quick · Tab to Talk");
|
|
355
2145
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
2146
|
+
/** Talk owns the bottom surface unless an overlay borrows the line input. */
|
|
2147
|
+
const talkComposerActive = mode === "talk" &&
|
|
2148
|
+
!confirm &&
|
|
2149
|
+
!picker &&
|
|
2150
|
+
!sheet &&
|
|
2151
|
+
!paletteOpen &&
|
|
2152
|
+
!switcherOpen &&
|
|
2153
|
+
!activityOpen;
|
|
2154
|
+
const focus = confirm
|
|
2155
|
+
? "confirm"
|
|
2156
|
+
: picker
|
|
2157
|
+
? "picker"
|
|
2158
|
+
: sheet
|
|
2159
|
+
? "sheet"
|
|
2160
|
+
: paletteOpen
|
|
2161
|
+
? "palette"
|
|
2162
|
+
: switcherOpen
|
|
2163
|
+
? "context"
|
|
2164
|
+
: activityOpen
|
|
2165
|
+
? "activity"
|
|
2166
|
+
: mode === "author" && authorViewEffective !== "home"
|
|
2167
|
+
? AUTHOR_VIEW_FOCUS[authorViewEffective]
|
|
2168
|
+
: mode === "talk" && talkFocus === "threads"
|
|
2169
|
+
? "talk-threads"
|
|
2170
|
+
: mode;
|
|
2171
|
+
const placeholder = confirm
|
|
2172
|
+
? `type "${confirm.phrase}" to confirm · Esc cancel`
|
|
2173
|
+
: picker
|
|
2174
|
+
? "filter · ↑↓ move · Enter select · Esc cancel"
|
|
2175
|
+
: sheet
|
|
2176
|
+
? "value · Enter next · + advanced · Esc cancel"
|
|
2177
|
+
: paletteOpen
|
|
2178
|
+
? "search actions…"
|
|
2179
|
+
: switcherOpen
|
|
2180
|
+
? "↑↓ rows · Enter change · Esc close"
|
|
2181
|
+
: activityOpen
|
|
2182
|
+
? "↑↓ move · r retry failed · c copy output · Esc close"
|
|
2183
|
+
: authorViewEffective === "diff"
|
|
2184
|
+
? fileDiff
|
|
2185
|
+
? "↑↓/PgUp/PgDn scroll · Esc back to tree"
|
|
2186
|
+
: "↑↓ tree · Enter open/toggle · s split/unified · PgUp/PgDn scroll · Esc"
|
|
2187
|
+
: authorViewEffective === "validation"
|
|
2188
|
+
? "v re-run · ↑↓ scroll · p push · Esc back"
|
|
2189
|
+
: authorViewEffective === "versions"
|
|
2190
|
+
? "↑↓ scroll · publish/archive via ^K · Esc back"
|
|
2191
|
+
: authorSession?.running
|
|
2192
|
+
? "type to queue a follow-up · ↑↓ units · Esc/^C cancel"
|
|
2193
|
+
: authorSession
|
|
2194
|
+
? "describe the next change · ↑↓ units · Enter expand"
|
|
2195
|
+
: backendDetection?.available &&
|
|
2196
|
+
scope.loggedIn &&
|
|
2197
|
+
hasAgent
|
|
2198
|
+
? `describe a change for ${authorBackend.label} · v d p l r o quick · ^K`
|
|
2199
|
+
: "v d p l r o quick actions · ^K all actions";
|
|
2200
|
+
const pickerFilter = picker ? effectivePickerFilter(input) : undefined;
|
|
2201
|
+
const pickerVisible = picker
|
|
2202
|
+
? filterPickerItems(picker.items, pickerFilter)
|
|
2203
|
+
: [];
|
|
2204
|
+
const pickerCursor = picker
|
|
2205
|
+
? clampPickerCursor(picker.cursor, pickerVisible.length)
|
|
2206
|
+
: 0;
|
|
2207
|
+
// Keep the highlighted row on screen when the list is long.
|
|
2208
|
+
const PICKER_PAGE = pickerPageSize();
|
|
2209
|
+
const pickerWindowStart = pickerVisible.length <= PICKER_PAGE
|
|
2210
|
+
? 0
|
|
2211
|
+
: Math.max(0, Math.min(pickerCursor - Math.floor(PICKER_PAGE / 3), pickerVisible.length - PICKER_PAGE));
|
|
2212
|
+
const pickerWindow = pickerVisible.slice(pickerWindowStart, pickerWindowStart + PICKER_PAGE);
|
|
2213
|
+
return (_jsxs("box", { style: { flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }, children: [_jsx(Breadcrumb, { scope: scope, working: working }), _jsx(ModeNav, { active: mode }), _jsx("box", { style: {
|
|
2214
|
+
flexDirection: "column",
|
|
2215
|
+
flexGrow: 1,
|
|
2216
|
+
flexShrink: 1,
|
|
2217
|
+
flexBasis: 0,
|
|
2218
|
+
minHeight: 3,
|
|
2219
|
+
width: "100%",
|
|
2220
|
+
overflow: "hidden",
|
|
2221
|
+
paddingTop: 1,
|
|
2222
|
+
}, children: picker ? (_jsx(PickerPane, { items: pickerWindow, cursor: pickerCursor, windowStart: pickerWindowStart, total: pickerVisible.length, title: picker.command.name, filter: pickerFilter, dismissHint: picker.command.name === "place-onboard"
|
|
2223
|
+
? "Esc continue without place"
|
|
2224
|
+
: undefined })) : confirm ? (_jsx(ConfirmPane, { confirm: confirm })) : sheet && sheetCommand ? (_jsx(SheetPane, { sheet: sheet })) : paletteOpen ? (_jsx(ActionPalettePane, { actions: paletteMatches, cursor: paletteHighlight, query: input, context: context, pageSize: pickerPageSize() })) : switcherOpen ? (_jsx(ContextSwitcherPane, { scope: scope, cursor: clampPickerCursor(switcherCursor ?? 0, CONTEXT_ROWS.length) })) : activityOpen ? (_jsx(ActivityDrawerPane, { operations: orderedOps, cursor: clampPickerCursor(activityCursor ?? 0, orderedOps.length), pageSize: pickerPageSize(), now: Date.now() })) : mode === "author" ? (authorViewEffective === "home" ? (_jsx(AuthorHome, { scope: scope, snapshot: snapshot, validation: validation, frame: frame, working: working, version: VERSION, paneTitle: paneTitle, paneLines: paneLines, session: authorSession, sessionRows: sessionRowsView, sessionCursor: clampPickerCursor(sessionCursor, authorSession?.units.length ?? 0), sessionScrollRef: contentScrollRef, backendLabel: authorBackend.label, backendDetection: backendDetection })) : authorViewEffective === "diff" ? (_jsx(DiffView, { rows: diffRows, cursor: clampPickerCursor(diffCursor, diffRows.length), treeTitle: hasAgent
|
|
2225
|
+
? statusByPath.size > 0
|
|
2226
|
+
? `changes (${changesSummary(snapshot?.diff)})`
|
|
2227
|
+
: "files (clean)"
|
|
2228
|
+
: "files", preview: preview, style: diffStyleEffective, remoteTag: scope.version, expanded: Boolean(fileDiff), sidebarWidth: Math.max(24, Math.min(36, Math.floor(termWidth / 4))), pageSize: pickerPageSize(), scrollRef: contentScrollRef })) : authorViewEffective === "validation" ? (_jsx(ValidationViewPane, { record: validation, scrollRef: contentScrollRef })) : (_jsx(VersionsViewPane, { versions: versions ?? [], currentTag: scope.version, scrollRef: contentScrollRef }))) : (_jsx(TalkView, { chat: chat, rows: talkRowsView, cursor: talkCursorClamped, focusPane: talkFocus, layout: talkLayoutEffective, threads: threads, threadsLoading: threadsLoading, threadCursor: clampPickerCursor(threadCursor, threadsRowCount(threads)), threadId: threadId, localMode: localMode, trace: trace, vars: vars, inspectorMetaLines: inspectorMetaLines, inspectorFetch: inspectorFetch, paneTitle: paneTitle, paneLines: paneLines, pageSize: pickerPageSize(), scrollRef: contentScrollRef, inspectorScrollRef: inspectorScrollRef })) }), slashOpen && slashMatches.length > 0 && (_jsx("box", { title: " commands ", style: {
|
|
2229
|
+
border: true,
|
|
2230
|
+
borderStyle: "rounded",
|
|
2231
|
+
borderColor: TEAL_DIM,
|
|
2232
|
+
flexDirection: "column",
|
|
2233
|
+
flexShrink: 0,
|
|
2234
|
+
paddingLeft: 1,
|
|
2235
|
+
paddingRight: 1,
|
|
2236
|
+
}, children: slashMatches.map((cmd) => (_jsxs("text", { children: [_jsxs("span", { fg: TEAL, children: ["/", cmd.name.padEnd(16)] }), _jsx("span", { fg: DIM, children: cmd.summary })] }, cmd.name))) })), latestOp && !activityOpen && (_jsx(ActivitySummaryLine, { operation: latestOp, now: Date.now() })), toast && (_jsxs("text", { fg: DIM, style: { paddingLeft: 1, flexShrink: 0 }, children: ["\u21B3 ", toast] })), talkComposerActive ? (_jsx(Composer, { targetLabel: `${scope.agent !== "—" ? scope.agent : "agent"} on ${localMode ? "local bundle" : `${scope.env} · ${talkVersionLabel}`}`, draft: composer, disabledReason: composerReason, pending: talkPending, taRef: composerRef, onDraftChange: setComposer, onSend: handleComposerSend })) : (_jsx("box", { style: {
|
|
362
2237
|
border: true,
|
|
363
2238
|
borderStyle: "rounded",
|
|
364
2239
|
borderColor: working ? AMBER : TEAL_DIM,
|
|
365
2240
|
focusedBorderColor: TEAL,
|
|
366
2241
|
height: 3,
|
|
2242
|
+
flexShrink: 0,
|
|
367
2243
|
paddingLeft: 1,
|
|
368
2244
|
paddingRight: 1,
|
|
369
|
-
}, children: _jsx("input", { focused: true, value: input, placeholder: placeholder, placeholderColor: FAINT, textColor: FG, focusedTextColor: FG, cursorColor: TEAL, backgroundColor: "transparent", focusedBackgroundColor: "transparent", onInput: (value) =>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
flexDirection: "column",
|
|
383
|
-
alignItems: "center",
|
|
384
|
-
width: 44,
|
|
385
|
-
paddingTop: 1,
|
|
386
|
-
paddingBottom: 1,
|
|
387
|
-
}, children: [frame.map((line, i) => (_jsx("text", { fg: working ? AMBER : TEAL, children: line }, i))), _jsxs("text", { fg: FG, children: [_jsx("b", { children: "apollo" }), " ", _jsxs("span", { fg: DIM, children: ["v", VERSION] })] }), _jsx("text", { fg: DIM, children: "Manage and operate Apollo agents" }), agentPath && _jsx("text", { fg: FAINT, children: agentPath })] }), _jsx("text", { children: " " }), _jsx("text", { fg: DIM, children: "/status /push /chat /mockdb /help" })] }));
|
|
388
|
-
}
|
|
389
|
-
function ChatPane({ chat, trace, localMode, vars, }) {
|
|
390
|
-
return (_jsxs("box", { style: { flexDirection: "column", flexGrow: 1 }, children: [_jsxs("text", { fg: FAINT, children: ["thread \u00B7 user=cli \u00B7 local=", localMode ? "on" : "off", " \u00B7 trace=", trace ? "on" : "off", vars.length > 0 ? ` · vars: ${vars.join(", ")}` : ""] }), _jsx("text", { children: " " }), _jsx("scrollbox", { style: { flexGrow: 1, flexDirection: "column" }, stickyScroll: true, children: chat.length === 0 ? (_jsx("text", { fg: FAINT, children: " (empty thread \u2014 type a message or /new)" })) : (chat.map((turn, i) => (_jsx("text", { fg: turn.role === "you" ? FG : turn.role === "agent" ? TEAL : AMBER, children: ` ${turn.role.padEnd(6)} ${turn.text}` }, i)))) })] }));
|
|
391
|
-
}
|
|
392
|
-
function BundlePane() {
|
|
393
|
-
return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("text", { fg: FG, children: _jsx("b", { children: " Bundle" }) }), _jsx("text", { fg: DIM, children: " Validate \u00B7 Diff vs baseline \u00B7 Diff vs remote" }), _jsx("text", { fg: DIM, children: " Pull\u2026 \u00B7 Push\u2026 \u00B7 Versions" }), _jsx("text", { children: " " }), _jsx("text", { fg: FAINT, children: " Open a command sheet with /push, /pull, /validate, /diff" })] }));
|
|
394
|
-
}
|
|
395
|
-
function MockDbPane() {
|
|
396
|
-
return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("text", { fg: FG, children: _jsx("b", { children: " Mock DB" }) }), _jsx("text", { fg: DIM, children: " Status \u00B7 Collections \u00B7 Endpoints \u00B7 Sessions" }), _jsx("text", { children: " " }), _jsx("text", { fg: FAINT, children: " /provision /wire /seed \u2014 or use the Scope agent context" })] }));
|
|
397
|
-
}
|
|
398
|
-
function ScopePane({ scope }) {
|
|
399
|
-
return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("text", { fg: FG, children: _jsx("b", { children: " Scope" }) }), _jsxs("text", { fg: DIM, children: [" env: ", scope.env] }), _jsxs("text", { fg: DIM, children: [" agent: ", scope.agent] }), _jsxs("text", { fg: DIM, children: [" version: ", scope.version] }), _jsxs("text", { fg: scope.loggedIn ? DIM : AMBER, children: [" ", "auth: ", scope.loggedIn ? "logged in" : "not logged in"] }), _jsx("text", { children: " " }), _jsx("text", { fg: FAINT, children: " /login /logout \u2014 pickers for env/org/project/agent live here" })] }));
|
|
400
|
-
}
|
|
401
|
-
function SheetPane({ sheet }) {
|
|
402
|
-
const fields = visibleFields(sheet);
|
|
403
|
-
return (_jsxs("box", { title: ` /${sheet.title} `, style: {
|
|
404
|
-
border: true,
|
|
405
|
-
borderStyle: "rounded",
|
|
406
|
-
borderColor: TEAL_DIM,
|
|
407
|
-
flexDirection: "column",
|
|
408
|
-
paddingLeft: 1,
|
|
409
|
-
paddingRight: 1,
|
|
410
|
-
}, children: [fields.map((field, i) => {
|
|
411
|
-
const raw = sheet.values[field.name];
|
|
412
|
-
const display = field.control === "toggle" ? (raw ? "[x]" : "[ ]") : String(raw ?? "");
|
|
413
|
-
const active = i === sheet.focused;
|
|
414
|
-
return (_jsxs("text", { fg: active ? FG : DIM, children: [active ? "› " : " ", field.label.padEnd(18), " ", display, field.required && !display ? _jsx("span", { fg: FAINT, children: " (required)" }) : null] }, field.name));
|
|
415
|
-
}), sheet.error && _jsxs("text", { fg: RED, children: ["! ", sheet.error] }), _jsx("text", { fg: FAINT, children: "type value + Enter \u00B7 Enter skip \u00B7 Tab move \u00B7 Esc cancel" })] }));
|
|
416
|
-
}
|
|
417
|
-
function Palette({ matches }) {
|
|
418
|
-
return (_jsx("box", { title: " commands ", style: {
|
|
419
|
-
border: true,
|
|
420
|
-
borderStyle: "rounded",
|
|
421
|
-
borderColor: TEAL_DIM,
|
|
422
|
-
flexDirection: "column",
|
|
423
|
-
paddingLeft: 1,
|
|
424
|
-
paddingRight: 1,
|
|
425
|
-
}, children: matches.map((cmd) => (_jsxs("text", { children: [_jsxs("span", { fg: TEAL, children: ["/", cmd.name.padEnd(12)] }), _jsx("span", { fg: DIM, children: cmd.summary })] }, cmd.name))) }));
|
|
2245
|
+
}, children: _jsx("input", { focused: true, value: input, placeholder: placeholder, placeholderColor: FAINT, textColor: FG, focusedTextColor: FG, cursorColor: TEAL, backgroundColor: "transparent", focusedBackgroundColor: "transparent", onInput: (value) => {
|
|
2246
|
+
setInput(value);
|
|
2247
|
+
if (picker) {
|
|
2248
|
+
const nextFilter = effectivePickerFilter(value);
|
|
2249
|
+
const prevFilter = effectivePickerFilter(input);
|
|
2250
|
+
if (nextFilter !== prevFilter) {
|
|
2251
|
+
setPicker({ ...picker, cursor: 0 });
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
if (paletteCursor !== undefined && value !== input) {
|
|
2255
|
+
setPaletteCursor(0);
|
|
2256
|
+
}
|
|
2257
|
+
}, onSubmit: (value) => handleSubmit(typeof value === "string" ? value : input) }) })), _jsx(FooterHints, { focus: focus, version: VERSION, copilot: Boolean(backendDetection?.available && scope.loggedIn && hasAgent) })] }));
|
|
426
2258
|
}
|
|
427
2259
|
export async function runOpenTui() {
|
|
428
2260
|
const context = await resolveContext({});
|
|
@@ -433,8 +2265,11 @@ export async function runOpenTui() {
|
|
|
433
2265
|
compact: true,
|
|
434
2266
|
});
|
|
435
2267
|
const renderer = await createCliRenderer({
|
|
436
|
-
|
|
2268
|
+
// Ctrl+C is an app key (cancel turn / clear input / quit) — see matchCtrlC.
|
|
2269
|
+
exitOnCtrlC: false,
|
|
437
2270
|
targetFps: 30,
|
|
2271
|
+
// Wheel-scrolling of content panes; keyboard scroll is driven centrally.
|
|
2272
|
+
useMouse: true,
|
|
438
2273
|
});
|
|
439
2274
|
const root = createRoot(renderer);
|
|
440
2275
|
const code = await new Promise((resolve) => {
|
|
@@ -442,8 +2277,6 @@ export async function runOpenTui() {
|
|
|
442
2277
|
});
|
|
443
2278
|
root.unmount();
|
|
444
2279
|
renderer.destroy();
|
|
445
|
-
// The raw-mode stdin handle keeps the event loop alive after destroy;
|
|
446
|
-
// exit explicitly so /quit actually returns to the shell.
|
|
447
2280
|
process.exit(code);
|
|
448
2281
|
}
|
|
449
2282
|
//# sourceMappingURL=opentui-app.js.map
|