@aui.io/apollo 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/node_modules/@aui.io/apollo-cli/bin/apollo.js +8 -5
  2. package/node_modules/@aui.io/apollo-cli/dist/src/commands/operate.js +3 -10
  3. package/node_modules/@aui.io/apollo-cli/dist/src/commands/operate.js.map +1 -1
  4. package/node_modules/@aui.io/apollo-cli/package.json +3 -3
  5. package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.d.ts +13 -0
  6. package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js +17 -0
  7. package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js.map +1 -1
  8. package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.d.ts +17 -0
  9. package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js +27 -0
  10. package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js.map +1 -1
  11. package/node_modules/@aui.io/apollo-core/package.json +1 -1
  12. package/node_modules/@aui.io/apollo-tui/dist/src/actions/ActionPalette.d.ts +13 -0
  13. package/node_modules/@aui.io/apollo-tui/dist/src/actions/ActionPalette.js +47 -0
  14. package/node_modules/@aui.io/apollo-tui/dist/src/actions/ActionPalette.js.map +1 -0
  15. package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.d.ts +28 -0
  16. package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js +219 -0
  17. package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js.map +1 -0
  18. package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivityDrawer.d.ts +8 -0
  19. package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivityDrawer.js +43 -0
  20. package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivityDrawer.js.map +1 -0
  21. package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivitySummary.d.ts +5 -0
  22. package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivitySummary.js +26 -0
  23. package/node_modules/@aui.io/apollo-tui/dist/src/activity/ActivitySummary.js.map +1 -0
  24. package/node_modules/@aui.io/apollo-tui/dist/src/activity/operationStore.d.ts +67 -0
  25. package/node_modules/@aui.io/apollo-tui/dist/src/activity/operationStore.js +138 -0
  26. package/node_modules/@aui.io/apollo-tui/dist/src/activity/operationStore.js.map +1 -0
  27. package/node_modules/@aui.io/apollo-tui/dist/src/author/AuthorHome.d.ts +27 -0
  28. package/node_modules/@aui.io/apollo-tui/dist/src/author/AuthorHome.js +92 -0
  29. package/node_modules/@aui.io/apollo-tui/dist/src/author/AuthorHome.js.map +1 -0
  30. package/node_modules/@aui.io/apollo-tui/dist/src/author/DiffView.d.ts +46 -0
  31. package/node_modules/@aui.io/apollo-tui/dist/src/author/DiffView.js +87 -0
  32. package/node_modules/@aui.io/apollo-tui/dist/src/author/DiffView.js.map +1 -0
  33. package/node_modules/@aui.io/apollo-tui/dist/src/author/SessionPane.d.ts +15 -0
  34. package/node_modules/@aui.io/apollo-tui/dist/src/author/SessionPane.js +38 -0
  35. package/node_modules/@aui.io/apollo-tui/dist/src/author/SessionPane.js.map +1 -0
  36. package/node_modules/@aui.io/apollo-tui/dist/src/author/ValidationView.d.ts +11 -0
  37. package/node_modules/@aui.io/apollo-tui/dist/src/author/ValidationView.js +34 -0
  38. package/node_modules/@aui.io/apollo-tui/dist/src/author/ValidationView.js.map +1 -0
  39. package/node_modules/@aui.io/apollo-tui/dist/src/author/VersionsView.d.ts +9 -0
  40. package/node_modules/@aui.io/apollo-tui/dist/src/author/VersionsView.js +31 -0
  41. package/node_modules/@aui.io/apollo-tui/dist/src/author/VersionsView.js.map +1 -0
  42. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/claudeCode.d.ts +23 -0
  43. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/claudeCode.js +293 -0
  44. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/claudeCode.js.map +1 -0
  45. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/index.d.ts +4 -0
  46. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/index.js +13 -0
  47. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/index.js.map +1 -0
  48. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/sessions.d.ts +3 -0
  49. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/sessions.js +36 -0
  50. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/sessions.js.map +1 -0
  51. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/types.d.ts +85 -0
  52. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/types.js +11 -0
  53. package/node_modules/@aui.io/apollo-tui/dist/src/author/backend/types.js.map +1 -0
  54. package/node_modules/@aui.io/apollo-tui/dist/src/author/data.d.ts +78 -0
  55. package/node_modules/@aui.io/apollo-tui/dist/src/author/data.js +279 -0
  56. package/node_modules/@aui.io/apollo-tui/dist/src/author/data.js.map +1 -0
  57. package/node_modules/@aui.io/apollo-tui/dist/src/author/session.d.ts +64 -0
  58. package/node_modules/@aui.io/apollo-tui/dist/src/author/session.js +319 -0
  59. package/node_modules/@aui.io/apollo-tui/dist/src/author/session.js.map +1 -0
  60. package/node_modules/@aui.io/apollo-tui/dist/src/author/tree.d.ts +25 -0
  61. package/node_modules/@aui.io/apollo-tui/dist/src/author/tree.js +69 -0
  62. package/node_modules/@aui.io/apollo-tui/dist/src/author/tree.js.map +1 -0
  63. package/node_modules/@aui.io/apollo-tui/dist/src/context/ContextSwitcher.d.ts +23 -0
  64. package/node_modules/@aui.io/apollo-tui/dist/src/context/ContextSwitcher.js +60 -0
  65. package/node_modules/@aui.io/apollo-tui/dist/src/context/ContextSwitcher.js.map +1 -0
  66. package/node_modules/@aui.io/apollo-tui/dist/src/index.d.ts +17 -1
  67. package/node_modules/@aui.io/apollo-tui/dist/src/index.js +22 -1
  68. package/node_modules/@aui.io/apollo-tui/dist/src/index.js.map +1 -1
  69. package/node_modules/@aui.io/apollo-tui/dist/src/keyprobe.d.ts +1 -0
  70. package/node_modules/@aui.io/apollo-tui/dist/src/keyprobe.js +51 -0
  71. package/node_modules/@aui.io/apollo-tui/dist/src/keyprobe.js.map +1 -0
  72. package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.d.ts +25 -0
  73. package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js +143 -19
  74. package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js.map +1 -1
  75. package/node_modules/@aui.io/apollo-tui/dist/src/lib/clipboard.d.ts +7 -0
  76. package/node_modules/@aui.io/apollo-tui/dist/src/lib/clipboard.js +33 -0
  77. package/node_modules/@aui.io/apollo-tui/dist/src/lib/clipboard.js.map +1 -0
  78. package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.d.ts +1 -1
  79. package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js +53 -10
  80. package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js.map +1 -1
  81. package/node_modules/@aui.io/apollo-tui/dist/src/lib/ide.d.ts +16 -0
  82. package/node_modules/@aui.io/apollo-tui/dist/src/lib/ide.js +39 -0
  83. package/node_modules/@aui.io/apollo-tui/dist/src/lib/ide.js.map +1 -0
  84. package/node_modules/@aui.io/apollo-tui/dist/src/lib/keydiag.d.ts +17 -0
  85. package/node_modules/@aui.io/apollo-tui/dist/src/lib/keydiag.js +76 -0
  86. package/node_modules/@aui.io/apollo-tui/dist/src/lib/keydiag.js.map +1 -0
  87. package/node_modules/@aui.io/apollo-tui/dist/src/lib/keys.d.ts +64 -0
  88. package/node_modules/@aui.io/apollo-tui/dist/src/lib/keys.js +105 -0
  89. package/node_modules/@aui.io/apollo-tui/dist/src/lib/keys.js.map +1 -0
  90. package/node_modules/@aui.io/apollo-tui/dist/src/lib/modes.d.ts +18 -0
  91. package/node_modules/@aui.io/apollo-tui/dist/src/lib/modes.js +24 -0
  92. package/node_modules/@aui.io/apollo-tui/dist/src/lib/modes.js.map +1 -0
  93. package/node_modules/@aui.io/apollo-tui/dist/src/lib/place.d.ts +7 -0
  94. package/node_modules/@aui.io/apollo-tui/dist/src/lib/place.js +17 -1
  95. package/node_modules/@aui.io/apollo-tui/dist/src/lib/place.js.map +1 -1
  96. package/node_modules/@aui.io/apollo-tui/dist/src/lib/scope.d.ts +25 -6
  97. package/node_modules/@aui.io/apollo-tui/dist/src/lib/scope.js +108 -17
  98. package/node_modules/@aui.io/apollo-tui/dist/src/lib/scope.js.map +1 -1
  99. package/node_modules/@aui.io/apollo-tui/dist/src/lib/sheet.d.ts +3 -1
  100. package/node_modules/@aui.io/apollo-tui/dist/src/lib/sheet.js +2 -1
  101. package/node_modules/@aui.io/apollo-tui/dist/src/lib/sheet.js.map +1 -1
  102. package/node_modules/@aui.io/apollo-tui/dist/src/opentui-app.js +1802 -245
  103. package/node_modules/@aui.io/apollo-tui/dist/src/opentui-app.js.map +1 -1
  104. package/node_modules/@aui.io/apollo-tui/dist/src/shell/Breadcrumb.d.ts +15 -0
  105. package/node_modules/@aui.io/apollo-tui/dist/src/shell/Breadcrumb.js +22 -0
  106. package/node_modules/@aui.io/apollo-tui/dist/src/shell/Breadcrumb.js.map +1 -0
  107. package/node_modules/@aui.io/apollo-tui/dist/src/shell/FooterHints.d.ts +8 -0
  108. package/node_modules/@aui.io/apollo-tui/dist/src/shell/FooterHints.js +23 -0
  109. package/node_modules/@aui.io/apollo-tui/dist/src/shell/FooterHints.js.map +1 -0
  110. package/node_modules/@aui.io/apollo-tui/dist/src/shell/ModeNav.d.ts +4 -0
  111. package/node_modules/@aui.io/apollo-tui/dist/src/shell/ModeNav.js +7 -0
  112. package/node_modules/@aui.io/apollo-tui/dist/src/shell/ModeNav.js.map +1 -0
  113. package/node_modules/@aui.io/apollo-tui/dist/src/talk/Composer.d.ts +20 -0
  114. package/node_modules/@aui.io/apollo-tui/dist/src/talk/Composer.js +29 -0
  115. package/node_modules/@aui.io/apollo-tui/dist/src/talk/Composer.js.map +1 -0
  116. package/node_modules/@aui.io/apollo-tui/dist/src/talk/InspectorPane.d.ts +15 -0
  117. package/node_modules/@aui.io/apollo-tui/dist/src/talk/InspectorPane.js +27 -0
  118. package/node_modules/@aui.io/apollo-tui/dist/src/talk/InspectorPane.js.map +1 -0
  119. package/node_modules/@aui.io/apollo-tui/dist/src/talk/TalkView.d.ts +30 -0
  120. package/node_modules/@aui.io/apollo-tui/dist/src/talk/TalkView.js +60 -0
  121. package/node_modules/@aui.io/apollo-tui/dist/src/talk/TalkView.js.map +1 -0
  122. package/node_modules/@aui.io/apollo-tui/dist/src/talk/ThreadsPane.d.ts +17 -0
  123. package/node_modules/@aui.io/apollo-tui/dist/src/talk/ThreadsPane.js +61 -0
  124. package/node_modules/@aui.io/apollo-tui/dist/src/talk/ThreadsPane.js.map +1 -0
  125. package/node_modules/@aui.io/apollo-tui/dist/src/talk/data.d.ts +107 -0
  126. package/node_modules/@aui.io/apollo-tui/dist/src/talk/data.js +235 -0
  127. package/node_modules/@aui.io/apollo-tui/dist/src/talk/data.js.map +1 -0
  128. package/node_modules/@aui.io/apollo-tui/dist/src/theme/tokens.d.ts +8 -0
  129. package/node_modules/@aui.io/apollo-tui/dist/src/theme/tokens.js +9 -0
  130. package/node_modules/@aui.io/apollo-tui/dist/src/theme/tokens.js.map +1 -0
  131. package/node_modules/@aui.io/apollo-tui/dist/src/ui/ConfirmPane.d.ts +4 -0
  132. package/node_modules/@aui.io/apollo-tui/dist/src/ui/ConfirmPane.js +15 -0
  133. package/node_modules/@aui.io/apollo-tui/dist/src/ui/ConfirmPane.js.map +1 -0
  134. package/node_modules/@aui.io/apollo-tui/dist/src/ui/OutputPane.d.ts +5 -0
  135. package/node_modules/@aui.io/apollo-tui/dist/src/ui/OutputPane.js +16 -0
  136. package/node_modules/@aui.io/apollo-tui/dist/src/ui/OutputPane.js.map +1 -0
  137. package/node_modules/@aui.io/apollo-tui/dist/src/ui/PickerPane.d.ts +10 -0
  138. package/node_modules/@aui.io/apollo-tui/dist/src/ui/PickerPane.js +29 -0
  139. package/node_modules/@aui.io/apollo-tui/dist/src/ui/PickerPane.js.map +1 -0
  140. package/node_modules/@aui.io/apollo-tui/dist/src/ui/SheetPane.d.ts +4 -0
  141. package/node_modules/@aui.io/apollo-tui/dist/src/ui/SheetPane.js +22 -0
  142. package/node_modules/@aui.io/apollo-tui/dist/src/ui/SheetPane.js.map +1 -0
  143. package/node_modules/@aui.io/apollo-tui/package.json +2 -2
  144. package/package.json +4 -4
  145. package/node_modules/@aui.io/apollo-tui/dist/src/lib/tabs.d.ts +0 -10
  146. package/node_modules/@aui.io/apollo-tui/dist/src/lib/tabs.js +0 -19
  147. package/node_modules/@aui.io/apollo-tui/dist/src/lib/tabs.js.map +0 -1
@@ -1,37 +1,77 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
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
- * Global slash command desk; tabs are views. Business logic in apollo-core
6
- * via lib/actions.ts.
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.
7
8
  */
8
- import { useEffect, useRef, useState } from "react";
9
- import { createCliRenderer } from "@opentui/core";
10
- import { createRoot, useKeyboard } from "@opentui/react";
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";
11
13
  import { resolveContext } from "@aui.io/apollo-core/config/index.js";
14
+ import { listVersions } from "@aui.io/apollo-core/services/bundles.js";
12
15
  import { createClients } from "@aui.io/apollo-core/services/clients.js";
13
- import { sendMessage } from "@aui.io/apollo-core/services/messaging.js";
16
+ import { listAgentThreads, listThreads, resolveSendVersion, sendMessage, } from "@aui.io/apollo-core/services/messaging.js";
14
17
  import { GlyphController } from "./components/glyph.js";
15
- import { applyPick, chatOverlay, destructiveSpec, loadPickerCatalog, refreshContext, runDestructive, runSheetAction, runSimpleCommand, } from "./lib/actions.js";
16
- import { filterCommands, helpLines, PLACE_ONBOARD_COMMAND, resolveCommand, } from "./lib/commands.js";
18
+ import { applyPick, chatOverlay, destructiveSpec, IMPORT_LIVE_PREFIX, loadPickerCatalog, refreshContext, runDestructive, runSheetAction, runSimpleCommand, } from "./lib/actions.js";
19
+ import { filterCommands, PLACE_ONBOARD_COMMAND, resolveCommand, SLASH_COMMANDS, } from "./lib/commands.js";
17
20
  import { checkConfirm, createConfirm } from "./lib/confirm.js";
18
- import { clampPickerCursor, effectivePickerFilter, filterPickerItems, pickerColumnWidths, pickerPageSize, resolvePickerAnswer, truncatePickerText, } from "./lib/picker.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";
19
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 { scopeFromContext } from "./lib/scope.js";
22
- import { TABS, nextTab, prevTab, tabByIndex } from "./lib/tabs.js";
23
- const TEAL = "#4FB8B0";
24
- const TEAL_DIM = "#2E6B66";
25
- const AMBER = "#E6A23C";
26
- const RED = "#D46A6A";
27
- const DIM = "#8A8A8A";
28
- const FAINT = "#5C5C5C";
29
- const FG = "#D8D8D8";
30
- const VERSION = process.env.npm_package_version ?? "0.1.7";
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
+ };
31
71
  function App({ context: initialContext, initialScope, glyph, onQuit }) {
32
72
  const [context, setContext] = useState(initialContext);
33
73
  const [scope, setScope] = useState(initialScope);
34
- const [tab, setTab] = useState("overview");
74
+ const [mode, setMode] = useState("author");
35
75
  const [input, setInput] = useState("");
36
76
  const [chat, setChat] = useState([]);
37
77
  const [threadId, setThreadId] = useState();
@@ -40,53 +80,874 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
40
80
  const [sheetCommand, setSheetCommand] = useState();
41
81
  const [picker, setPicker] = useState();
42
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();
43
87
  const [paneTitle, setPaneTitle] = useState();
44
88
  const [paneLines, setPaneLines] = useState([]);
45
89
  const [working, setWorking] = useState(false);
46
90
  const [trace, setTrace] = useState(false);
47
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);
48
98
  const [vars, setVars] = useState([]);
49
99
  const [frame, setFrame] = useState(glyph.frame());
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);
50
165
  /** Soft place gate offered once per session (pick / skip / already had place). */
51
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);
52
247
  const stateRef = useRef({
53
- tab,
248
+ mode,
54
249
  sheet,
55
250
  sheetCommand,
56
251
  picker,
57
252
  confirm,
253
+ paletteCursor,
254
+ switcherCursor,
255
+ activityCursor,
58
256
  context,
59
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,
60
285
  });
61
- stateRef.current = { tab, sheet, sheetCommand, picker, confirm, context, input };
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;
62
333
  useEffect(() => {
63
- if (tab !== "overview")
334
+ if (mode !== "author" || !authorEmpty)
64
335
  return;
65
336
  const id = setInterval(() => setFrame(glyph.frame()), 33);
66
337
  return () => clearInterval(id);
67
- }, [tab, glyph]);
338
+ }, [mode, authorEmpty, glyph]);
68
339
  useEffect(() => {
69
340
  glyph.setWorking(working);
70
341
  }, [working, glyph]);
71
- const paletteOpen = !sheet && !picker && !confirm && input.startsWith("/");
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]);
72
681
  const paletteMatches = paletteOpen
73
- ? filterCommands(input, tab === "overview" ? undefined : tab).slice(0, 10)
682
+ ? searchActions(REGISTRY, input, mode)
74
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) : [];
75
690
  useKeyboard((key) => {
76
691
  if (key.eventType === "release")
77
692
  return;
78
693
  const current = stateRef.current;
79
- if (current.picker && (key.name === "up" || key.name === "down")) {
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) {
80
722
  const visible = filterPickerItems(current.picker.items, effectivePickerFilter(current.input));
81
723
  if (visible.length === 0)
82
724
  return;
83
- const delta = key.name === "down" ? 1 : -1;
725
+ const delta = arrow === "down" ? 1 : -1;
84
726
  setPicker({
85
727
  ...current.picker,
86
728
  cursor: clampPickerCursor(current.picker.cursor + delta, visible.length),
87
729
  });
88
730
  return;
89
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
+ }
90
951
  if (key.name === "tab") {
91
952
  if (current.picker) {
92
953
  const visible = filterPickerItems(current.picker.items, effectivePickerFilter(current.input));
@@ -100,14 +961,28 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
100
961
  }
101
962
  if (current.sheet) {
102
963
  setSheet(moveFocus(current.sheet, key.shift ? -1 : 1));
964
+ return;
965
+ }
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;
103
976
  }
104
- else if (!current.confirm) {
105
- setTab((t) => (key.shift ? prevTab(t) : nextTab(t)));
977
+ if (current.activityCursor !== undefined)
978
+ return;
979
+ if (!current.confirm && matchModeToggle(key)) {
980
+ setMode((m) => otherMode(m));
106
981
  setInput("");
107
982
  }
108
983
  return;
109
984
  }
110
- if (key.name === "escape") {
985
+ if (matchEscape(key)) {
111
986
  if (current.confirm) {
112
987
  setConfirm(undefined);
113
988
  setToast("Cancelled");
@@ -120,7 +995,7 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
120
995
  setSheetCommand(current.picker.command);
121
996
  }
122
997
  setToast(wasPlaceOnboard
123
- ? "Continuing without a workspace — /workspace when ready"
998
+ ? "Continuing without a workspace — pick one anytime (^G)"
124
999
  : "Cancelled");
125
1000
  }
126
1001
  else if (current.sheet) {
@@ -128,17 +1003,42 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
128
1003
  setSheetCommand(undefined);
129
1004
  setToast("Cancelled");
130
1005
  }
131
- else {
1006
+ else if (current.paletteCursor !== undefined) {
1007
+ setPaletteCursor(undefined);
132
1008
  setInput("");
133
1009
  }
134
- return;
135
- }
136
- if ((key.ctrl || key.meta) && /^[1-5]$/.test(key.name)) {
137
- const def = tabByIndex(Number(key.name));
138
- if (def) {
139
- setTab(def.id);
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
+ }
1038
+ else {
140
1039
  setInput("");
141
1040
  }
1041
+ return;
142
1042
  }
143
1043
  });
144
1044
  async function applyResult(result) {
@@ -158,12 +1058,57 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
158
1058
  }
159
1059
  if (result.toast)
160
1060
  setToast(result.toast);
161
- if (result.jumpTab)
162
- setTab(result.jumpTab);
163
- if (result.chat)
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);
164
1074
  setChat(result.chat);
165
- if (result.threadId)
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) {
166
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
+ }
167
1112
  if (result.paneLines) {
168
1113
  setPaneLines(result.paneLines);
169
1114
  setPaneTitle(result.paneTitle ?? "result");
@@ -175,11 +1120,19 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
175
1120
  const next = await refreshContext();
176
1121
  setContext(next);
177
1122
  setScope(scopeFromContext(next));
178
- await maybeOfferPlace(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
+ }
179
1132
  }
180
1133
  }
181
- async function openPlaceOnboarding() {
182
- await openPick(PLACE_ONBOARD_COMMAND, "place-onboard");
1134
+ async function openPlaceOnboarding(ctx) {
1135
+ await openPick(PLACE_ONBOARD_COMMAND, "place-onboard", undefined, undefined, ctx);
183
1136
  }
184
1137
  async function maybeOfferPlace(ctx) {
185
1138
  if (placeGateOffered.current)
@@ -188,11 +1141,11 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
188
1141
  placeGateOffered.current = true;
189
1142
  return;
190
1143
  }
191
- // Logged-out: wait for /login (mount toast covers the hint).
1144
+ // Logged-out: wait for login (mount toast covers the hint).
192
1145
  if (!ctx.token)
193
1146
  return;
194
1147
  placeGateOffered.current = true;
195
- await openPlaceOnboarding();
1148
+ await openPlaceOnboarding(ctx);
196
1149
  }
197
1150
  useEffect(() => {
198
1151
  if (hasPlace(initialContext)) {
@@ -200,39 +1153,358 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
200
1153
  return;
201
1154
  }
202
1155
  if (!initialContext.token) {
203
- setToast("Not in a workspace · /login, then pick a place — Esc skips");
1156
+ setToast("Not in a workspace · log in, then pick a place — Esc skips");
204
1157
  return;
205
1158
  }
206
1159
  placeGateOffered.current = true;
207
1160
  void openPlaceOnboarding();
208
1161
  // Once on mount — placeGateOffered guards re-entry.
209
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
+ }
210
1458
  function ensureAuth(command) {
211
- if (!command.requiresAuth || scope.loggedIn)
1459
+ if (!command.requiresAuth || stateRef.current.scope.loggedIn)
212
1460
  return true;
213
- setToast("Login required run /login");
214
- setInput("/login");
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");
215
1467
  return false;
216
1468
  }
217
- async function openPick(command, kind, sheetModel, sheetField) {
1469
+ async function openPick(command, kind, sheetModel, sheetField, contextOverride) {
218
1470
  setWorking(true);
219
1471
  try {
220
- const items = await loadPickerCatalog(kind, stateRef.current.context);
1472
+ let ctx = contextOverride ?? stateRef.current.context;
1473
+ // The import version picker lists the *sheet's* chosen agent — nothing
1474
+ // is bound in context yet, so inject the id the user just picked.
1475
+ if (kind === "import-version") {
1476
+ const chosen = sheetModel?.values.agent;
1477
+ if (typeof chosen === "string" && chosen) {
1478
+ ctx = { ...ctx, agentId: chosen };
1479
+ }
1480
+ }
1481
+ const items = await loadPickerCatalog(kind, ctx);
221
1482
  if (items.length === 0) {
222
- setToast(`No ${kind} options`);
1483
+ setToast(kind === "import-version"
1484
+ ? "This agent has no versions to import — push one first"
1485
+ : `No ${kind} options`);
223
1486
  return;
224
1487
  }
225
1488
  if (items.length === 1 && !sheetField && command.name !== "place-onboard") {
226
1489
  await finishPick(command, items[0].id, sheetModel, sheetField);
227
1490
  return;
228
1491
  }
1492
+ // Live is always row 0 (= the default selection); its absence is
1493
+ // called out rather than silently defaulting to some other tag.
1494
+ const note = kind === "import-version" &&
1495
+ !items[0]?.label.startsWith(IMPORT_LIVE_PREFIX)
1496
+ ? "⚠ no live version — pick a tag"
1497
+ : undefined;
229
1498
  setSheet(undefined);
1499
+ setPaletteCursor(undefined);
1500
+ setSwitcherCursor(undefined);
230
1501
  setPicker({
231
1502
  command,
232
1503
  items,
233
1504
  cursor: 0,
234
1505
  ...(sheetField ? { sheetField } : {}),
235
1506
  ...(sheetModel ? { sheet: sheetModel } : {}),
1507
+ ...(note ? { note } : {}),
236
1508
  });
237
1509
  setInput("");
238
1510
  }
@@ -255,7 +1527,8 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
255
1527
  setToast(`Selected ${id}`);
256
1528
  return;
257
1529
  }
258
- if (command.destructive && (command.name === "version-publish" || command.name === "version-archive")) {
1530
+ if (command.destructive &&
1531
+ (command.name === "version-publish" || command.name === "version-archive")) {
259
1532
  setConfirm(createConfirm(command.name, "yes", command.name, { id }));
260
1533
  return;
261
1534
  }
@@ -271,24 +1544,42 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
271
1544
  }
272
1545
  }
273
1546
  async function runCommand(command, args) {
274
- if (command.jumpTab) {
275
- setTab(command.jumpTab);
276
- setToast(`Opened ${command.jumpTab}`);
277
- return;
278
- }
279
1547
  if (command.name === "help") {
280
- setPaneTitle("help");
281
- setPaneLines(helpLines());
282
- setTab("overview");
283
- setToast("Help — see pane");
1548
+ setPaletteCursor(0);
1549
+ setInput("");
284
1550
  return;
285
1551
  }
286
1552
  if (command.name === "quit") {
287
1553
  onQuit(0);
288
1554
  return;
289
1555
  }
1556
+ if (command.name === "activity") {
1557
+ setActivityCursor((c) => (c === undefined ? 0 : undefined));
1558
+ return;
1559
+ }
1560
+ // Structured diff view is local-first; works logged out (content diff
1561
+ // fetches will surface their own auth errors).
1562
+ if (command.name === "diff" && !args.trim()) {
1563
+ openDiffView();
1564
+ return;
1565
+ }
1566
+ if (command.name === "diff-style") {
1567
+ const current = stateRef.current;
1568
+ const inDiffView = current.mode === "author" && current.authorView === "diff";
1569
+ if (inDiffView || openDiffView())
1570
+ toggleDiffStyle();
1571
+ return;
1572
+ }
290
1573
  if (!ensureAuth(command))
291
1574
  return;
1575
+ if (command.name === "validate") {
1576
+ await runStructuredValidate();
1577
+ return;
1578
+ }
1579
+ if (command.name === "versions") {
1580
+ await openVersionsView();
1581
+ return;
1582
+ }
292
1583
  if (command.name === "trace") {
293
1584
  setTrace((v) => {
294
1585
  setToast(`trace ${v ? "off" : "on"}`);
@@ -298,11 +1589,19 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
298
1589
  }
299
1590
  if (command.name === "local") {
300
1591
  setLocalMode((v) => {
301
- setToast(`local mode ${v ? "off" : "on"}`);
1592
+ setToast(v
1593
+ ? "local bundle off — replies run the published version"
1594
+ : "local bundle on — replies run this checkout");
302
1595
  return !v;
303
1596
  });
304
1597
  return;
305
1598
  }
1599
+ // Turn diagnostics land in the inspector (^T/^D reach here too).
1600
+ if (command.name === "turn-trace" || command.name === "turn-debug") {
1601
+ setMode("talk");
1602
+ await inspectTurn(command.name === "turn-trace" ? "trace" : "debug");
1603
+ return;
1604
+ }
306
1605
  const confirmSpec = destructiveSpec(command.name, stateRef.current.context);
307
1606
  if (confirmSpec && !command.pick) {
308
1607
  setConfirm(createConfirm(confirmSpec.title, confirmSpec.phrase, confirmSpec.action, confirmSpec.args));
@@ -326,7 +1625,7 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
326
1625
  return;
327
1626
  }
328
1627
  if (command.fields?.length) {
329
- let model = createSheet(command.name, command.fields);
1628
+ let model = createSheet(command.name, command.fields, sheetInfoFor(command.name));
330
1629
  if (args)
331
1630
  model = setFocusedValue(model, args);
332
1631
  setSheet(model);
@@ -338,16 +1637,50 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
338
1637
  return;
339
1638
  }
340
1639
  setWorking(true);
1640
+ const opId = trackOperation(command.name, {
1641
+ mode: "simple",
1642
+ name: command.name,
1643
+ args,
1644
+ });
341
1645
  try {
342
- await applyResult(await runSimpleCommand(command.name, stateRef.current.context, args));
1646
+ const result = await runSimpleCommand(command.name,
1647
+ // Chat commands (`new` creates threads) run under the talk target so
1648
+ // a pinned tag or forced live applies to them too.
1649
+ command.group === "chat" ? talkContext() : stateRef.current.context, args);
1650
+ finishOperation(opId, result);
1651
+ await applyResult(result);
343
1652
  }
344
1653
  catch (error) {
1654
+ failOperation(opId, error);
345
1655
  setToast(error instanceof Error ? error.message : String(error));
346
1656
  }
347
1657
  finally {
348
1658
  setWorking(false);
349
1659
  }
350
1660
  }
1661
+ /** Sheet-backed action run — used by sheet submit and drawer retry. */
1662
+ function runSheetOp(command, values) {
1663
+ void (async () => {
1664
+ setWorking(true);
1665
+ const opId = trackOperation(command.name, {
1666
+ mode: "sheet",
1667
+ name: command.name,
1668
+ values,
1669
+ });
1670
+ try {
1671
+ const result = await runSheetAction(command.name, values, stateRef.current.context);
1672
+ finishOperation(opId, result);
1673
+ await applyResult(result);
1674
+ }
1675
+ catch (err) {
1676
+ failOperation(opId, err);
1677
+ setToast(err instanceof Error ? err.message : String(err));
1678
+ }
1679
+ finally {
1680
+ setWorking(false);
1681
+ }
1682
+ })();
1683
+ }
351
1684
  function submitSheet(model, command) {
352
1685
  const error = validateSheet(model);
353
1686
  if (error) {
@@ -364,18 +1697,7 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
364
1697
  }
365
1698
  return;
366
1699
  }
367
- void (async () => {
368
- setWorking(true);
369
- try {
370
- await applyResult(await runSheetAction(command.name, model.values, stateRef.current.context));
371
- }
372
- catch (err) {
373
- setToast(err instanceof Error ? err.message : String(err));
374
- }
375
- finally {
376
- setWorking(false);
377
- }
378
- })();
1700
+ runSheetOp(command, model.values);
379
1701
  }
380
1702
  async function handleSheetLine(model, command, line) {
381
1703
  if (line === "+" || line.toLowerCase() === "advanced") {
@@ -437,53 +1759,219 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
437
1759
  }
438
1760
  }
439
1761
  }
440
- async function sendChat(text) {
441
- setChat((c) => [...c, { role: "you", text }]);
442
- if (!scope.loggedIn) {
443
- setChat((c) => [...c, { role: "system", text: "Not logged in · Run /login" }]);
1762
+ function clearComposer() {
1763
+ composerRef.current?.setText("");
1764
+ setComposer("");
1765
+ }
1766
+ /**
1767
+ * The context sends actually run under: the session talk target overrides
1768
+ * the bound tag ("latest" forces live resolution by dropping the pin).
1769
+ */
1770
+ function talkContext() {
1771
+ const { context: ctx, talkTag: tag } = stateRef.current;
1772
+ if (!tag)
1773
+ return ctx;
1774
+ if (tag === "latest") {
1775
+ // No pin → version resolution falls to the live version.
1776
+ return { ...ctx, versionTag: undefined };
1777
+ }
1778
+ return { ...ctx, versionTag: tag };
1779
+ }
1780
+ /** Client-side cancel: the API has no abort, so the reply is discarded. */
1781
+ function cancelTalkSend() {
1782
+ sendSeqRef.current += 1;
1783
+ setChat((c) => cancelSend(c, Date.now()));
1784
+ setWorking(false);
1785
+ setToast("Cancelled — the reply is discarded when it arrives");
1786
+ }
1787
+ /**
1788
+ * Honest pending: optimistic user turn + a waiting placeholder that shows
1789
+ * real elapsed time — the messaging API is request/response, so there is
1790
+ * no token streaming to fake (TUI_DESIGN §9.5).
1791
+ */
1792
+ async function sendTalkMessage(text) {
1793
+ const current = stateRef.current;
1794
+ const reason = composerDisabledReason(current.scope, current.localMode, current.scope.agentPath);
1795
+ if (reason) {
1796
+ setToast(`Can’t send — ${reason}`);
444
1797
  return;
445
1798
  }
1799
+ if (current.talkPending) {
1800
+ setToast("Waiting for the reply — Esc cancels it first");
1801
+ return;
1802
+ }
1803
+ const seq = ++sendSeqRef.current;
1804
+ const startedAt = Date.now();
1805
+ setChat((c) => beginSend(c, text, startedAt));
1806
+ setTalkFocus("chat");
446
1807
  setWorking(true);
447
1808
  try {
448
- const clients = createClients(stateRef.current.context);
449
- const result = await sendMessage(stateRef.current.context, clients.runtime, {
1809
+ // Session talk target applied: pinned tag or forced live.
1810
+ const ctx = talkContext();
1811
+ const clients = createClients(ctx);
1812
+ const local = Boolean(current.localMode && ctx.agentRoot);
1813
+ // The runtime needs an explicit version id (bound tag, else the live
1814
+ // version) — it does not resolve tags itself. Local mode skips this:
1815
+ // the inline bundle carries the program.
1816
+ const versionId = local
1817
+ ? undefined
1818
+ : await resolveSendVersion(ctx, clients.management);
1819
+ const result = await sendMessage(ctx, clients.runtime, {
450
1820
  text,
451
- ...(threadId ? { threadId } : {}),
1821
+ ...(versionId ? { versionId } : {}),
1822
+ ...(current.threadId ? { threadId: current.threadId } : {}),
452
1823
  includeTrace: true,
453
- ...(localMode && stateRef.current.context.agentRoot
454
- ? { localDir: stateRef.current.context.agentRoot }
455
- : {}),
456
- variables: vars,
1824
+ ...(local && ctx.agentRoot ? { localDir: ctx.agentRoot } : {}),
1825
+ variables: current.vars,
457
1826
  });
458
- if (result.thread_id)
1827
+ if (seq !== sendSeqRef.current)
1828
+ return; // cancelled or superseded
1829
+ if (result.thread_id) {
459
1830
  setThreadId(result.thread_id);
460
- const reply = result.message.text ?? JSON.stringify(result);
461
- setChat((c) => [
462
- ...c,
463
- {
464
- role: "agent",
465
- text: reply,
466
- messageId: result.message.id,
467
- interactionId: result.message.id,
468
- ...(result.message.trace_info
469
- ? { trace: result.message.trace_info }
1831
+ if (!current.threads?.some((t) => t.id === result.thread_id)) {
1832
+ setThreads(undefined); // pane learns the new thread lazily
1833
+ }
1834
+ }
1835
+ const message = result.message;
1836
+ const tokens = message.input_tokens !== undefined || message.output_tokens !== undefined
1837
+ ? {
1838
+ ...(message.input_tokens !== undefined
1839
+ ? { input: message.input_tokens }
470
1840
  : {}),
471
- },
472
- ]);
1841
+ ...(message.output_tokens !== undefined
1842
+ ? { output: message.output_tokens }
1843
+ : {}),
1844
+ }
1845
+ : undefined;
1846
+ setChat((c) => settleSend(c, {
1847
+ role: "agent",
1848
+ text: message.text ?? JSON.stringify(result),
1849
+ messageId: message.id,
1850
+ interactionId: message.id,
1851
+ ...(message.trace_info ? { trace: message.trace_info } : {}),
1852
+ at: Date.now(),
1853
+ durationMs: Date.now() - startedAt,
1854
+ ...(tokens ? { tokens } : {}),
1855
+ }));
1856
+ }
1857
+ catch (error) {
1858
+ if (seq !== sendSeqRef.current)
1859
+ return;
1860
+ setChat((c) => settleSend(c, {
1861
+ role: "system",
1862
+ text: error instanceof Error ? error.message : String(error),
1863
+ at: Date.now(),
1864
+ }));
1865
+ // Failed sends keep the message for a retry (unless a new draft began).
1866
+ if (!stateRef.current.composer) {
1867
+ composerRef.current?.setText(text);
1868
+ setComposer(text);
1869
+ }
1870
+ setToast("Send failed — message kept in the composer");
1871
+ }
1872
+ finally {
1873
+ if (seq === sendSeqRef.current)
1874
+ setWorking(false);
1875
+ }
1876
+ }
1877
+ /**
1878
+ * ^T/^D — diagnostics for the selected turn, attached where they belong:
1879
+ * the inspector pane when it fits, the inline overlay otherwise. A
1880
+ * non-agent selection falls back to the newest reply (and moves to it).
1881
+ */
1882
+ async function inspectTurn(kind) {
1883
+ const current = stateRef.current;
1884
+ const cursor = clampPickerCursor(current.talkCursor, current.chat.length);
1885
+ let target = cursor;
1886
+ let ordinal = agentOrdinal(current.chat, cursor);
1887
+ if (ordinal === undefined) {
1888
+ target = lastAgentTurnIndex(current.chat);
1889
+ if (target < 0) {
1890
+ setToast("No agent replies yet — send a message first");
1891
+ return;
1892
+ }
1893
+ ordinal = agentOrdinal(current.chat, target);
1894
+ }
1895
+ setWorking(true);
1896
+ try {
1897
+ const result = await chatOverlay(kind, current.chat, current.threadId, current.context, ordinal !== undefined ? String(ordinal) : undefined);
1898
+ if (result.error) {
1899
+ setToast(result.error);
1900
+ return;
1901
+ }
1902
+ if (current.talkLayout.inspector) {
1903
+ setMode("talk");
1904
+ if (target !== cursor)
1905
+ setTalkCursor(target);
1906
+ setInspectorFetch({ turn: target, kind, lines: result.paneLines ?? [] });
1907
+ setToast(`${kind} · reply #${ordinal ?? "?"} → inspector`);
1908
+ }
1909
+ else {
1910
+ await applyResult(result); // narrow terminals: inline overlay
1911
+ }
473
1912
  }
474
1913
  catch (error) {
475
- setChat((c) => [
476
- ...c,
477
- {
478
- role: "system",
479
- text: error instanceof Error ? error.message : String(error),
480
- },
481
- ]);
1914
+ setToast(error instanceof Error ? error.message : String(error));
482
1915
  }
483
1916
  finally {
484
1917
  setWorking(false);
485
1918
  }
486
1919
  }
1920
+ /** Enter in the composer: threads focus opens; otherwise send the draft. */
1921
+ function handleComposerSend() {
1922
+ const current = stateRef.current;
1923
+ if (current.talkFocus === "threads") {
1924
+ const count = threadsRowCount(current.threads);
1925
+ const row = clampPickerCursor(current.threadCursor, count);
1926
+ if (row === NEW_THREAD_ROW) {
1927
+ const cmd = SLASH_COMMANDS.find((c) => c.name === "new");
1928
+ if (cmd)
1929
+ void runCommand(cmd, "");
1930
+ return;
1931
+ }
1932
+ const picked = current.threads?.[row - 1];
1933
+ if (picked)
1934
+ void openThread(picked.id);
1935
+ return;
1936
+ }
1937
+ const text = (composerRef.current?.plainText ?? "").trim();
1938
+ if (!text)
1939
+ return;
1940
+ clearComposer();
1941
+ void sendTalkMessage(text);
1942
+ }
1943
+ async function openThread(id) {
1944
+ setWorking(true);
1945
+ try {
1946
+ await applyResult(await applyPick("thread", id, stateRef.current.context));
1947
+ }
1948
+ catch (error) {
1949
+ setToast(error instanceof Error ? error.message : String(error));
1950
+ }
1951
+ finally {
1952
+ setWorking(false);
1953
+ }
1954
+ }
1955
+ function runLine(line) {
1956
+ const named = line.slice(1);
1957
+ const { command, args } = resolveCommand(line);
1958
+ if (command) {
1959
+ void runCommand(command, args);
1960
+ return;
1961
+ }
1962
+ const aliasName = named.split(/\s+/)[0] ?? "";
1963
+ if (OLD_NAV_NAMES.has(aliasName.toLowerCase())) {
1964
+ setToast("Navigation: Tab or ^1 Author / ^2 Talk");
1965
+ return;
1966
+ }
1967
+ const action = actionByAlias(REGISTRY, aliasName);
1968
+ if (action) {
1969
+ const rest = named.slice(aliasName.length).trim();
1970
+ void runCommand(action.command, rest);
1971
+ return;
1972
+ }
1973
+ setToast("Unknown command — ^K to browse actions");
1974
+ }
487
1975
  function handleSubmit(raw) {
488
1976
  const line = raw.trim();
489
1977
  setInput("");
@@ -496,10 +1984,16 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
496
1984
  setConfirm(undefined);
497
1985
  void (async () => {
498
1986
  setWorking(true);
1987
+ // Destructive ops are tracked but never offered a retry — a rerun
1988
+ // must go back through the typed confirmation.
1989
+ const opId = trackOperation(spec.action);
499
1990
  try {
500
- await applyResult(await runDestructive(spec.action, current.context, spec.args ?? {}));
1991
+ const result = await runDestructive(spec.action, current.context, spec.args ?? {});
1992
+ finishOperation(opId, result);
1993
+ await applyResult(result);
501
1994
  }
502
1995
  catch (error) {
1996
+ failOperation(opId, error);
503
1997
  setToast(error instanceof Error ? error.message : String(error));
504
1998
  }
505
1999
  finally {
@@ -548,50 +2042,179 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
548
2042
  void handleSheetLine(current.sheet, current.sheetCommand, line);
549
2043
  return;
550
2044
  }
551
- if (line.startsWith("/")) {
552
- const { command, args } = resolveCommand(line);
553
- if (!command) {
554
- setToast("Unknown command — try /help");
555
- setInput("/");
2045
+ if (current.paletteCursor !== undefined) {
2046
+ const matches = searchActions(REGISTRY, line, current.mode);
2047
+ if (matches.length === 0) {
2048
+ setToast("No matching actions");
556
2049
  return;
557
2050
  }
558
- void runCommand(command, args);
2051
+ const highlight = clampPickerCursor(current.paletteCursor, matches.length);
2052
+ const action = matches[highlight];
2053
+ const reason = action.disabledReason?.(current.context);
2054
+ setPaletteCursor(undefined);
2055
+ if (reason && !action.command.requiresAuth) {
2056
+ // Auth gaps route to login via ensureAuth; other gaps just explain.
2057
+ setToast(`${action.title} — ${reason}`);
2058
+ return;
2059
+ }
2060
+ void runCommand(action.command, "");
559
2061
  return;
560
2062
  }
561
- if (line === "")
562
- return;
563
- if (current.tab === "chat") {
564
- const overlay = /^[td](?:\s+(\d+))?$/i.exec(line);
565
- if (overlay) {
566
- const kind = line[0].toLowerCase() === "d" ? "debug" : "trace";
567
- void (async () => {
568
- setWorking(true);
569
- try {
570
- await applyResult(await chatOverlay(kind, chat, threadId, current.context, overlay[1]));
571
- }
572
- catch (error) {
573
- setToast(error instanceof Error ? error.message : String(error));
574
- }
575
- finally {
576
- setWorking(false);
577
- }
578
- })();
2063
+ if (current.switcherCursor !== undefined) {
2064
+ const row = CONTEXT_ROWS[clampPickerCursor(current.switcherCursor, CONTEXT_ROWS.length)];
2065
+ setSwitcherCursor(undefined);
2066
+ if (!row)
579
2067
  return;
2068
+ const command = SLASH_COMMANDS.find((c) => c.name === row.id);
2069
+ if (command)
2070
+ void runCommand(command, "");
2071
+ return;
2072
+ }
2073
+ // Activity drawer: Enter retries the highlighted failed operation.
2074
+ if (current.activityCursor !== undefined) {
2075
+ const op = current.orderedOps[clampPickerCursor(current.activityCursor, current.orderedOps.length)];
2076
+ if (op && line === "") {
2077
+ if (op.status === "failed" && op.retry)
2078
+ retryOperation(op);
2079
+ else
2080
+ setToast("r retry (failed) · c copy output · Esc close");
2081
+ }
2082
+ return;
2083
+ }
2084
+ if (line.startsWith("/")) {
2085
+ runLine(line);
2086
+ return;
2087
+ }
2088
+ // Enter in the file tree: toggle a dir, or expand a file full-width.
2089
+ if (line === "" &&
2090
+ current.mode === "author" &&
2091
+ current.authorView === "diff" &&
2092
+ !current.fileDiff) {
2093
+ const rows = current.diffRows;
2094
+ const row = rows[clampPickerCursor(current.diffCursor, rows.length)];
2095
+ if (row?.kind === "dir") {
2096
+ setDiffCollapsed((prev) => {
2097
+ const next = new Set(prev);
2098
+ if (next.has(row.path))
2099
+ next.delete(row.path);
2100
+ else
2101
+ next.add(row.path);
2102
+ return next;
2103
+ });
2104
+ }
2105
+ else if (row) {
2106
+ setPreviewPath(row.path);
2107
+ setFileDiff({ path: row.path });
580
2108
  }
581
- void sendChat(line);
582
2109
  return;
583
2110
  }
584
- setToast("Type / for commands, or use the Chat tab to message the agent");
2111
+ // Enter on a session unit: expand/collapse its body (tool input/result).
2112
+ if (line === "" &&
2113
+ current.mode === "author" &&
2114
+ current.authorView === "home" &&
2115
+ current.authorSession) {
2116
+ const units = current.authorSession.units;
2117
+ const unit = units[clampPickerCursor(current.sessionCursor, units.length)];
2118
+ if (unit &&
2119
+ unit.body.length > 0 &&
2120
+ (unit.kind === "action" || unit.kind === "thinking")) {
2121
+ setSessionExpanded((prev) => {
2122
+ const next = new Set(prev);
2123
+ if (next.has(unit.id))
2124
+ next.delete(unit.id);
2125
+ else
2126
+ next.add(unit.id);
2127
+ return next;
2128
+ });
2129
+ }
2130
+ return;
2131
+ }
2132
+ if (line === "")
2133
+ return;
2134
+ // `s` toggles the diff style (split ↔ unified) while in the diff view.
2135
+ if (current.mode === "author" &&
2136
+ current.authorView === "diff" &&
2137
+ line.toLowerCase() === "s") {
2138
+ toggleDiffStyle();
2139
+ return;
2140
+ }
2141
+ // Talk mode never reaches here without an overlay — the composer owns
2142
+ // sending, and ^T/^D/^N replaced the old typed t/d lines.
2143
+ // Author mode: single-letter quick actions; free text on the home
2144
+ // screen is a copilot turn (Phase 5); elsewhere it points at ^K.
2145
+ const quick = AUTHOR_QUICK[line.toLowerCase()];
2146
+ if (quick) {
2147
+ const command = SLASH_COMMANDS.find((c) => c.name === quick);
2148
+ if (command)
2149
+ void runCommand(command, "");
2150
+ return;
2151
+ }
2152
+ if (line === "?" || line.toLowerCase() === "h") {
2153
+ setPaletteCursor(0);
2154
+ return;
2155
+ }
2156
+ if (current.authorView === "home" &&
2157
+ current.scope.loggedIn &&
2158
+ current.scope.agent !== "—") {
2159
+ void startAuthorTurn(line);
2160
+ return;
2161
+ }
2162
+ setToast("^K actions · v/d/p/l/r/o quick · Tab to Talk");
585
2163
  }
2164
+ /** Talk owns the bottom surface unless an overlay borrows the line input. */
2165
+ const talkComposerActive = mode === "talk" &&
2166
+ !confirm &&
2167
+ !picker &&
2168
+ !sheet &&
2169
+ !paletteOpen &&
2170
+ !switcherOpen &&
2171
+ !activityOpen;
2172
+ const focus = confirm
2173
+ ? "confirm"
2174
+ : picker
2175
+ ? "picker"
2176
+ : sheet
2177
+ ? "sheet"
2178
+ : paletteOpen
2179
+ ? "palette"
2180
+ : switcherOpen
2181
+ ? "context"
2182
+ : activityOpen
2183
+ ? "activity"
2184
+ : mode === "author" && authorViewEffective !== "home"
2185
+ ? AUTHOR_VIEW_FOCUS[authorViewEffective]
2186
+ : mode === "talk" && talkFocus === "threads"
2187
+ ? "talk-threads"
2188
+ : mode;
586
2189
  const placeholder = confirm
587
2190
  ? `type "${confirm.phrase}" to confirm · Esc cancel`
588
2191
  : picker
589
2192
  ? "filter · ↑↓ move · Enter select · Esc cancel"
590
- : tab === "chat"
591
- ? "message · t/d trace/debug last reply · / commands"
592
- : sheet
593
- ? "value · Enter next · + advanced · Esc cancel"
594
- : "/ for commands · Tab switches tabs";
2193
+ : sheet
2194
+ ? "value · Enter next · + advanced · Esc cancel"
2195
+ : paletteOpen
2196
+ ? "search actions…"
2197
+ : switcherOpen
2198
+ ? "↑↓ rows · Enter change · Esc close"
2199
+ : activityOpen
2200
+ ? "↑↓ move · r retry failed · c copy output · Esc close"
2201
+ : authorViewEffective === "diff"
2202
+ ? fileDiff
2203
+ ? "↑↓/PgUp/PgDn scroll · Esc back to tree"
2204
+ : "↑↓ tree · Enter open/toggle · s split/unified · PgUp/PgDn scroll · Esc"
2205
+ : authorViewEffective === "validation"
2206
+ ? "v re-run · ↑↓ scroll · p push · Esc back"
2207
+ : authorViewEffective === "versions"
2208
+ ? "↑↓ scroll · publish/archive via ^K · Esc back"
2209
+ : authorSession?.running
2210
+ ? "type to queue a follow-up · ↑↓ units · Esc/^C cancel"
2211
+ : authorSession
2212
+ ? "describe the next change · ↑↓ units · Enter expand"
2213
+ : backendDetection?.available &&
2214
+ scope.loggedIn &&
2215
+ hasAgent
2216
+ ? `describe a change for ${authorBackend.label} · v d p l r o quick · ^K`
2217
+ : "v d p l r o quick actions · ^K all actions";
595
2218
  const pickerFilter = picker ? effectivePickerFilter(input) : undefined;
596
2219
  const pickerVisible = picker
597
2220
  ? filterPickerItems(picker.items, pickerFilter)
@@ -605,15 +2228,38 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
605
2228
  ? 0
606
2229
  : Math.max(0, Math.min(pickerCursor - Math.floor(PICKER_PAGE / 3), pickerVisible.length - PICKER_PAGE));
607
2230
  const pickerWindow = pickerVisible.slice(pickerWindowStart, pickerWindowStart + PICKER_PAGE);
608
- const modalOpen = Boolean(picker || confirm || sheet);
609
- return (_jsxs("box", { style: { flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }, children: [_jsx(TabBar, { active: tab }), _jsx("box", { style: { flexDirection: "column", flexGrow: 1, paddingTop: 1 }, 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"
2231
+ 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: {
2232
+ flexDirection: "column",
2233
+ flexGrow: 1,
2234
+ flexShrink: 1,
2235
+ flexBasis: 0,
2236
+ minHeight: 3,
2237
+ width: "100%",
2238
+ overflow: "hidden",
2239
+ paddingTop: 1,
2240
+ }, children: picker ? (_jsx(PickerPane, { items: pickerWindow, cursor: pickerCursor, windowStart: pickerWindowStart, total: pickerVisible.length, title: picker.note
2241
+ ? `${picker.command.name} — ${picker.note}`
2242
+ : picker.command.name, filter: pickerFilter, dismissHint: picker.command.name === "place-onboard"
610
2243
  ? "Esc continue without place"
611
- : undefined })) : confirm ? (_jsx(ConfirmPane, { confirm: confirm })) : sheet && sheetCommand ? (_jsx(SheetPane, { sheet: sheet })) : (_jsxs(_Fragment, { children: [tab === "overview" && (_jsx(Overview, { frame: frame, working: working, agentPath: scope.agentPath, paneTitle: paneTitle, paneLines: paneLines })), tab === "chat" && (_jsx(ChatPane, { chat: chat, trace: trace, localMode: localMode, vars: vars, threadId: threadId, paneTitle: paneTitle, paneLines: paneLines })), tab === "bundle" && (_jsx(BundlePane, { paneTitle: paneTitle, paneLines: paneLines })), tab === "mockdb" && (_jsx(MockDbPane, { paneTitle: paneTitle, paneLines: paneLines })), tab === "scope" && _jsx(ScopePane, { scope: scope })] })) }), paletteOpen && !modalOpen && paletteMatches.length > 0 && (_jsx(Palette, { matches: paletteMatches })), toast && (_jsxs("text", { fg: DIM, style: { paddingLeft: 1 }, children: ["\u21B3 ", toast] })), !scope.loggedIn && (_jsx("box", { style: { flexDirection: "row", justifyContent: "flex-end" }, children: _jsx("text", { fg: AMBER, children: "Not logged in \u00B7 Run /login " }) })), _jsx("box", { style: {
2244
+ : 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
2245
+ ? statusByPath.size > 0
2246
+ ? `changes (${changesSummary(snapshot?.diff)})`
2247
+ : "files (clean)"
2248
+ : "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: {
2249
+ border: true,
2250
+ borderStyle: "rounded",
2251
+ borderColor: TEAL_DIM,
2252
+ flexDirection: "column",
2253
+ flexShrink: 0,
2254
+ paddingLeft: 1,
2255
+ paddingRight: 1,
2256
+ }, 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: {
612
2257
  border: true,
613
2258
  borderStyle: "rounded",
614
2259
  borderColor: working ? AMBER : TEAL_DIM,
615
2260
  focusedBorderColor: TEAL,
616
2261
  height: 3,
2262
+ flexShrink: 0,
617
2263
  paddingLeft: 1,
618
2264
  paddingRight: 1,
619
2265
  }, children: _jsx("input", { focused: true, value: input, placeholder: placeholder, placeholderColor: FAINT, textColor: FG, focusedTextColor: FG, cursorColor: TEAL, backgroundColor: "transparent", focusedBackgroundColor: "transparent", onInput: (value) => {
@@ -625,102 +2271,10 @@ function App({ context: initialContext, initialScope, glyph, onQuit }) {
625
2271
  setPicker({ ...picker, cursor: 0 });
626
2272
  }
627
2273
  }
628
- }, onSubmit: (value) => handleSubmit(typeof value === "string" ? value : input) }) }), _jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between" }, children: [_jsx("text", { fg: FAINT, children: " Tab tabs \u00B7 / commands \u00B7 Enter send \u00B7 Ctrl+C quit" }), _jsxs("text", { fg: FAINT, children: [scope.profile, "/", scope.env, " \u00B7 ", scope.workspace, " \u00B7 ", scope.agent, " "] })] })] }));
629
- }
630
- function TabBar({ active }) {
631
- return (_jsx("box", { style: { flexDirection: "row", paddingTop: 1 }, children: TABS.map((t) => t.id === active ? (_jsx("text", { fg: TEAL, children: _jsx("b", { children: ` ${t.label} ` }) }, t.id)) : (_jsx("text", { fg: DIM, children: ` ${t.label} ` }, t.id))) }));
632
- }
633
- function Overview({ frame, working, agentPath, paneTitle, paneLines, }) {
634
- return (_jsxs("box", { style: { flexDirection: "column", alignItems: "center", flexGrow: 1 }, children: [_jsxs("box", { style: {
635
- border: true,
636
- borderStyle: "rounded",
637
- borderColor: TEAL_DIM,
638
- flexDirection: "column",
639
- alignItems: "center",
640
- width: 44,
641
- paddingTop: 1,
642
- paddingBottom: 1,
643
- }, 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 /login /env /agent /push /help" }), paneTitle === "help" && paneLines.length > 0 && (_jsx("scrollbox", { style: { flexGrow: 1, width: "100%", marginTop: 1 }, children: paneLines.map((line, i) => (_jsx("text", { fg: FAINT, children: line }, i))) }))] }));
644
- }
645
- function ChatPane({ chat, trace, localMode, vars, threadId, paneTitle, paneLines, }) {
646
- const agentCount = chat.filter((t) => t.role === "agent").length;
647
- return (_jsxs("box", { style: { flexDirection: "column", flexGrow: 1 }, children: [_jsxs("text", { fg: FAINT, children: ["thread=", threadId?.slice(0, 12) ?? "new", " \u00B7 local=", localMode ? "on" : "off", " \u00B7 trace=", trace ? "on" : "off", " \u00B7 replies=", agentCount, vars.length > 0 ? ` · vars: ${vars.join(", ")}` : ""] }), _jsx("text", { fg: FAINT, children: " t / d = trace / debug last agent reply \u00B7 t 2 = 2nd reply" }), _jsx("text", { children: " " }), _jsxs("scrollbox", { style: { flexGrow: 1, flexDirection: "column" }, stickyScroll: true, children: [chat.length === 0 ? (_jsx("text", { fg: FAINT, children: " (empty \u2014 type a message, /thread, 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)))), paneLines.length > 0 &&
648
- paneTitle &&
649
- /^(trace|debug)/i.test(paneTitle) ? (_jsxs(_Fragment, { children: [_jsx("text", { children: " " }), _jsxs("text", { fg: AMBER, children: ["\u2014 ", paneTitle, " \u2014"] }), paneLines.map((line, i) => (_jsx("text", { fg: FAINT, children: line }, `p-${i}`)))] })) : null] })] }));
650
- }
651
- function ResultPane({ title, hints, paneTitle, paneLines, }) {
652
- return (_jsxs("box", { style: { flexDirection: "column", flexGrow: 1 }, children: [_jsx("text", { fg: FG, children: _jsxs("b", { children: [" ", title] }) }), hints.map((h, i) => (_jsxs("text", { fg: DIM, children: [" ", h] }, i))), _jsx("text", { children: " " }), paneLines.length > 0 ? (_jsxs("scrollbox", { style: { flexGrow: 1 }, children: [paneTitle && (_jsxs("text", { fg: TEAL, children: ["\u2014 ", paneTitle, " \u2014"] })), paneLines.map((line, i) => (_jsx("text", { fg: FAINT, children: line }, i)))] })) : (_jsx("text", { fg: FAINT, children: " Use / commands from any tab" }))] }));
653
- }
654
- function BundlePane({ paneTitle, paneLines, }) {
655
- return (_jsx(ResultPane, { title: "Bundle", hints: ["/validate /diff /pull /push /versions /version-publish"], paneTitle: paneTitle, paneLines: paneLines }));
656
- }
657
- function MockDbPane({ paneTitle, paneLines, }) {
658
- return (_jsx(ResultPane, { title: "Mock DB", hints: ["/provision /wire /seed /mdb-collection-* /mdb-endpoint-* /mdb-status"], paneTitle: paneTitle, paneLines: paneLines }));
659
- }
660
- function ScopePane({ scope }) {
661
- return (_jsxs("box", { style: { flexDirection: "column" }, children: [_jsx("text", { fg: FG, children: _jsx("b", { children: " Scope" }) }), _jsxs("text", { fg: DIM, children: [" profile: ", scope.profile] }), _jsxs("text", { fg: DIM, children: [" env: ", scope.env, " (account \u2014 no cd)"] }), _jsxs("text", { fg: DIM, children: [" workspace: ", scope.workspace] }), _jsxs("text", { fg: DIM, children: [" org: ", scope.org] }), _jsxs("text", { fg: DIM, children: [" project: ", scope.project] }), _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: " " }), _jsxs("text", { fg: FAINT, children: [" ", "/env /profile /workspace /org /project /agent /agent-import /login"] })] }));
662
- }
663
- function SheetPane({ sheet }) {
664
- const fields = visibleFields(sheet);
665
- return (_jsxs("box", { title: ` /${sheet.title} `, style: {
666
- border: true,
667
- borderStyle: "rounded",
668
- borderColor: TEAL_DIM,
669
- flexDirection: "column",
670
- flexGrow: 1,
671
- paddingLeft: 1,
672
- paddingRight: 1,
673
- }, children: [fields.map((field, i) => {
674
- const raw = sheet.values[field.name];
675
- const display = field.control === "toggle" ? (raw ? "[x]" : "[ ]") : String(raw ?? "");
676
- const active = i === sheet.focused;
677
- return (_jsxs("text", { fg: active ? FG : DIM, children: [active ? "› " : " ", field.label.padEnd(18), " ", display, field.control === "picker" ? _jsx("span", { fg: FAINT, children: " (picker)" }) : null, field.required && !display ? _jsx("span", { fg: FAINT, children: " (required)" }) : null] }, field.name));
678
- }), sheet.fields.some((f) => f.advanced) && (_jsxs("text", { fg: FAINT, children: [" ", sheet.advancedOpen ? "▾" : "▸", " advanced (+ to toggle)"] })), 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" })] }));
679
- }
680
- function PickerPane({ items, cursor, windowStart, total, title, filter, dismissHint, }) {
681
- const { nameWidth, idWidth } = pickerColumnWidths();
682
- return (_jsxs("box", { title: ` pick ${title}${filter ? ` · “${filter}”` : ""} `, style: {
683
- border: true,
684
- borderStyle: "rounded",
685
- borderColor: TEAL_DIM,
686
- flexDirection: "column",
687
- flexGrow: 1,
688
- paddingLeft: 1,
689
- paddingRight: 1,
690
- }, children: [items.length === 0 ? (_jsx("text", { fg: AMBER, children: " No matches" })) : (items.map((item, offset) => {
691
- const index = windowStart + offset;
692
- const active = index === cursor;
693
- const marker = active ? "›" : " ";
694
- const number = String(index + 1).padStart(2);
695
- const name = truncatePickerText(item.label, nameWidth).padEnd(nameWidth);
696
- const secondary = item.detail && item.detail !== item.label
697
- ? truncatePickerText(item.detail, idWidth)
698
- : "";
699
- return (_jsxs("text", { fg: active ? TEAL : FG, children: [`${marker} ${number} ${name}`, secondary ? (_jsx("span", { fg: FAINT, children: ` ${secondary}` })) : null] }, `${item.id}-${index}`));
700
- })), _jsxs("text", { fg: FAINT, children: [total > items.length
701
- ? ` ${cursor + 1}/${total} · ↑↓ move · type to filter · Enter`
702
- : ` ↑↓ move · type to filter · Enter select`, dismissHint ? ` · ${dismissHint}` : ""] })] }));
703
- }
704
- function ConfirmPane({ confirm }) {
705
- return (_jsxs("box", { title: ` confirm ${confirm.title} `, style: {
706
- border: true,
707
- borderStyle: "rounded",
708
- borderColor: AMBER,
709
- flexDirection: "column",
710
- flexGrow: 1,
711
- paddingLeft: 1,
712
- paddingRight: 1,
713
- }, children: [_jsxs("text", { fg: AMBER, children: ["Type ", _jsx("b", { children: confirm.phrase }), " to confirm"] }), confirm.error && _jsxs("text", { fg: RED, children: ["! ", confirm.error] })] }));
714
- }
715
- function Palette({ matches }) {
716
- return (_jsx("box", { title: " commands ", style: {
717
- border: true,
718
- borderStyle: "rounded",
719
- borderColor: TEAL_DIM,
720
- flexDirection: "column",
721
- paddingLeft: 1,
722
- paddingRight: 1,
723
- }, children: matches.map((cmd) => (_jsxs("text", { children: [_jsxs("span", { fg: TEAL, children: ["/", cmd.name.padEnd(16)] }), _jsx("span", { fg: DIM, children: cmd.summary })] }, cmd.name))) }));
2274
+ if (paletteCursor !== undefined && value !== input) {
2275
+ setPaletteCursor(0);
2276
+ }
2277
+ }, onSubmit: (value) => handleSubmit(typeof value === "string" ? value : input) }) })), _jsx(FooterHints, { focus: focus, version: VERSION, copilot: Boolean(backendDetection?.available && scope.loggedIn && hasAgent) })] }));
724
2278
  }
725
2279
  export async function runOpenTui() {
726
2280
  const context = await resolveContext({});
@@ -731,8 +2285,11 @@ export async function runOpenTui() {
731
2285
  compact: true,
732
2286
  });
733
2287
  const renderer = await createCliRenderer({
734
- exitOnCtrlC: true,
2288
+ // Ctrl+C is an app key (cancel turn / clear input / quit) — see matchCtrlC.
2289
+ exitOnCtrlC: false,
735
2290
  targetFps: 30,
2291
+ // Wheel-scrolling of content panes; keyboard scroll is driven centrally.
2292
+ useMouse: true,
736
2293
  });
737
2294
  const root = createRoot(renderer);
738
2295
  const code = await new Promise((resolve) => {