@esso0428/pi-subagents 0.15.5 → 0.15.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -19
- package/README.md +5 -5
- package/dist/agent-history-list.d.ts +19 -0
- package/dist/agent-history-list.d.ts.map +1 -0
- package/dist/agent-history-list.js +69 -0
- package/dist/agent-history-list.js.map +1 -0
- package/dist/agent-history.d.ts +2 -1
- package/dist/agent-history.d.ts.map +1 -0
- package/dist/agent-history.js +32 -34
- package/dist/agent-history.js.map +1 -0
- package/dist/agent-manager.d.ts +24 -8
- package/dist/agent-manager.d.ts.map +1 -0
- package/dist/agent-manager.js +171 -93
- package/dist/agent-manager.js.map +1 -0
- package/dist/agent-runner.d.ts +1 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +75 -86
- package/dist/agent-runner.js.map +1 -0
- package/dist/agent-types.d.ts +1 -0
- package/dist/agent-types.d.ts.map +1 -0
- package/dist/agent-types.js +28 -45
- package/dist/agent-types.js.map +1 -0
- package/dist/context.d.ts +1 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +3 -6
- package/dist/context.js.map +1 -0
- package/dist/cross-extension-rpc.d.ts +1 -0
- package/dist/cross-extension-rpc.d.ts.map +1 -0
- package/dist/cross-extension-rpc.js +6 -9
- package/dist/cross-extension-rpc.js.map +1 -0
- package/dist/custom-agents.d.ts +1 -0
- package/dist/custom-agents.d.ts.map +1 -0
- package/dist/custom-agents.js +16 -18
- package/dist/custom-agents.js.map +1 -0
- package/dist/default-agents.d.ts +1 -0
- package/dist/default-agents.d.ts.map +1 -0
- package/dist/default-agents.js +2 -4
- package/dist/default-agents.js.map +1 -0
- package/dist/enabled-models.d.ts +1 -0
- package/dist/enabled-models.d.ts.map +1 -0
- package/dist/enabled-models.js +12 -16
- package/dist/enabled-models.js.map +1 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +2 -4
- package/dist/env.js.map +1 -0
- package/dist/group-join.d.ts +1 -0
- package/dist/group-join.d.ts.map +1 -0
- package/dist/group-join.js +2 -5
- package/dist/group-join.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +306 -292
- package/dist/index.js.map +1 -0
- package/dist/invocation-config.d.ts +1 -0
- package/dist/invocation-config.d.ts.map +1 -0
- package/dist/invocation-config.js +3 -6
- package/dist/invocation-config.js.map +1 -0
- package/dist/memory.d.ts +1 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +25 -34
- package/dist/memory.js.map +1 -0
- package/dist/model-resolver.d.ts +1 -0
- package/dist/model-resolver.d.ts.map +1 -0
- package/dist/model-resolver.js +2 -4
- package/dist/model-resolver.js.map +1 -0
- package/dist/nico-overrides.d.ts +1 -0
- package/dist/nico-overrides.d.ts.map +1 -0
- package/dist/nico-overrides.js +15 -20
- package/dist/nico-overrides.js.map +1 -0
- package/dist/output-file.d.ts +1 -0
- package/dist/output-file.d.ts.map +1 -0
- package/dist/output-file.js +17 -22
- package/dist/output-file.js.map +1 -0
- package/dist/prompts.d.ts +1 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +2 -4
- package/dist/prompts.js.map +1 -0
- package/dist/schedule-store.d.ts +1 -0
- package/dist/schedule-store.d.ts.map +1 -0
- package/dist/schedule-store.js +17 -21
- package/dist/schedule-store.js.map +1 -0
- package/dist/schedule.d.ts +1 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +9 -12
- package/dist/schedule.js.map +1 -0
- package/dist/settings.d.ts +1 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +16 -23
- package/dist/settings.js.map +1 -0
- package/dist/skill-loader.d.ts +1 -0
- package/dist/skill-loader.d.ts.map +1 -0
- package/dist/skill-loader.js +21 -23
- package/dist/skill-loader.js.map +1 -0
- package/dist/status-note.d.ts +1 -0
- package/dist/status-note.d.ts.map +1 -0
- package/dist/status-note.js +2 -4
- package/dist/status-note.js.map +1 -0
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -4
- package/dist/types.js.map +1 -0
- package/dist/ui/agent-widget.d.ts +2 -5
- package/dist/ui/agent-widget.d.ts.map +1 -0
- package/dist/ui/agent-widget.js +29 -51
- package/dist/ui/agent-widget.js.map +1 -0
- package/dist/ui/ccstyle/diff/ansi-utils.d.ts +11 -0
- package/dist/ui/ccstyle/diff/ansi-utils.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/ansi-utils.js +145 -0
- package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -0
- package/dist/ui/ccstyle/diff/diff-presentation.d.ts +12 -0
- package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/diff-presentation.js +49 -0
- package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -0
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts +46 -0
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/diff-renderer.js +2044 -0
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -0
- package/dist/ui/ccstyle/diff/line-width-safety.d.ts +12 -0
- package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/line-width-safety.js +59 -0
- package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -0
- package/dist/ui/ccstyle/diff/render-utils.d.ts +6 -0
- package/dist/ui/ccstyle/diff/render-utils.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/render-utils.js +25 -0
- package/dist/ui/ccstyle/diff/render-utils.js.map +1 -0
- package/dist/ui/ccstyle/diff/types.d.ts +22 -0
- package/dist/ui/ccstyle/diff/types.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/types.js +10 -0
- package/dist/ui/ccstyle/diff/types.js.map +1 -0
- package/dist/ui/ccstyle/diff/write-display-utils.d.ts +2 -0
- package/dist/ui/ccstyle/diff/write-display-utils.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/write-display-utils.js +12 -0
- package/dist/ui/ccstyle/diff/write-display-utils.js.map +1 -0
- package/dist/ui/ccstyle/tool-renderer.d.ts +17 -0
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -0
- package/dist/ui/ccstyle/tool-renderer.js +58 -0
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -0
- package/dist/ui/ccstyle/tool-result.d.ts +24 -0
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -0
- package/dist/ui/ccstyle/tool-result.js +193 -0
- package/dist/ui/ccstyle/tool-result.js.map +1 -0
- package/dist/ui/conversation-blocks.d.ts +46 -0
- package/dist/ui/conversation-blocks.d.ts.map +1 -0
- package/dist/ui/conversation-blocks.js +306 -0
- package/dist/ui/conversation-blocks.js.map +1 -0
- package/dist/ui/conversation-nvim.d.ts +8 -0
- package/dist/ui/conversation-nvim.d.ts.map +1 -0
- package/dist/ui/conversation-nvim.js +117 -0
- package/dist/ui/conversation-nvim.js.map +1 -0
- package/dist/ui/conversation-role.d.ts +13 -0
- package/dist/ui/conversation-role.d.ts.map +1 -0
- package/dist/ui/conversation-role.js +39 -0
- package/dist/ui/conversation-role.js.map +1 -0
- package/dist/ui/conversation-search.d.ts +39 -0
- package/dist/ui/conversation-search.d.ts.map +1 -0
- package/dist/ui/conversation-search.js +124 -0
- package/dist/ui/conversation-search.js.map +1 -0
- package/dist/ui/conversation-timeline.d.ts +62 -0
- package/dist/ui/conversation-timeline.d.ts.map +1 -0
- package/dist/ui/conversation-timeline.js +263 -0
- package/dist/ui/conversation-timeline.js.map +1 -0
- package/dist/ui/conversation-viewer.d.ts +62 -20
- package/dist/ui/conversation-viewer.d.ts.map +1 -0
- package/dist/ui/conversation-viewer.js +449 -224
- package/dist/ui/conversation-viewer.js.map +1 -0
- package/dist/ui/fleet-list.d.ts +24 -11
- package/dist/ui/fleet-list.d.ts.map +1 -0
- package/dist/ui/fleet-list.js +86 -78
- package/dist/ui/fleet-list.js.map +1 -0
- package/dist/ui/schedule-menu.d.ts +1 -0
- package/dist/ui/schedule-menu.d.ts.map +1 -0
- package/dist/ui/schedule-menu.js +2 -4
- package/dist/ui/schedule-menu.js.map +1 -0
- package/dist/ui/viewer-keys.d.ts +1 -0
- package/dist/ui/viewer-keys.d.ts.map +1 -0
- package/dist/ui/viewer-keys.js +8 -10
- package/dist/ui/viewer-keys.js.map +1 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +5 -10
- package/dist/usage.js.map +1 -0
- package/dist/worktree.d.ts +1 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +27 -31
- package/dist/worktree.js.map +1 -0
- package/dist/write-execution.d.ts +42 -0
- package/dist/write-execution.d.ts.map +1 -0
- package/dist/write-execution.js +138 -0
- package/dist/write-execution.js.map +1 -0
- package/package.json +1 -1
- package/src/agent-history-list.ts +91 -0
- package/src/agent-history.ts +12 -17
- package/src/agent-manager.ts +169 -81
- package/src/agent-runner.ts +12 -13
- package/src/index.ts +117 -98
- package/src/output-file.ts +1 -1
- package/src/types.ts +4 -1
- package/src/ui/agent-widget.ts +3 -9
- package/src/ui/ccstyle/ATTRIBUTION.md +37 -0
- package/src/ui/ccstyle/diff/ansi-utils.ts +168 -0
- package/src/ui/ccstyle/diff/diff-presentation.ts +70 -0
- package/src/ui/ccstyle/diff/diff-renderer.ts +3148 -0
- package/src/ui/ccstyle/diff/line-width-safety.ts +92 -0
- package/src/ui/ccstyle/diff/render-utils.ts +32 -0
- package/src/ui/ccstyle/diff/types.ts +33 -0
- package/src/ui/ccstyle/diff/write-display-utils.ts +12 -0
- package/src/ui/ccstyle/tool-renderer.ts +71 -0
- package/src/ui/ccstyle/tool-result.ts +238 -0
- package/src/ui/conversation-blocks.ts +404 -0
- package/src/ui/conversation-nvim.ts +127 -0
- package/src/ui/conversation-role.ts +45 -0
- package/src/ui/conversation-search.ts +161 -0
- package/src/ui/conversation-timeline.ts +315 -0
- package/src/ui/conversation-viewer.ts +418 -210
- package/src/ui/fleet-list.ts +73 -52
- package/src/write-execution.ts +180 -0
- package/test/agent-history-list.test.ts +121 -0
- package/test/agent-manager-history.test.ts +59 -58
- package/test/agent-menu-history-navigation.test.ts +86 -0
- package/test/output-file-history.test.ts +50 -0
- package/test/ui/conversation-blocks.test.ts +103 -0
- package/test/ui/conversation-diff.test.ts +91 -0
- package/test/ui/conversation-search.test.ts +55 -0
- package/test/ui/conversation-viewer.test.ts +182 -0
- package/test/ui/fleet-list.test.ts +131 -0
- package/test/write-execution.test.ts +83 -0
- package/tsconfig.json +16 -0
- package/vitest.temp.config.mts +6 -0
- package/dist/ui/markdown-result.d.ts +0 -3
- package/dist/ui/markdown-result.js +0 -53
- package/src/ui/markdown-result.ts +0 -56
- package/test/agent-history.test.ts +0 -63
- package/test/ui/markdown-result.test.ts +0 -45
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* conversation-viewer.ts — Live conversation overlay
|
|
2
|
+
* conversation-viewer.ts — Live and historical conversation overlay.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The viewer deliberately keeps rendering state separate from the transcript:
|
|
5
|
+
* blocks are formatted data, while Markdown/ANSI lines are a bounded cache.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { copyToClipboard } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { type Component, Input, Key, matchesKey, type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
11
|
import type { AgentRecord } from "../types.js";
|
|
12
12
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
13
|
import type { Theme } from "./agent-widget.js";
|
|
14
14
|
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
15
|
+
import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
|
|
16
|
+
import { ConversationTimeline, type TimelineRenderLine } from "./conversation-timeline.js";
|
|
17
|
+
import { renderConversationRoleHeader } from "./conversation-role.js";
|
|
18
|
+
import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
|
|
19
|
+
import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
|
|
15
20
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
16
21
|
|
|
17
|
-
/** Base lines consumed by
|
|
22
|
+
/** Base lines consumed by the overlay: borders, header, separator and footer. */
|
|
18
23
|
const CHROME_LINES_BASE = 6;
|
|
19
24
|
const MIN_VIEWPORT = 3;
|
|
20
|
-
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
21
25
|
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
26
|
+
const MAX_COPY_CHARS = 500_000;
|
|
27
|
+
const MATCH_BG = "\x1b[48;5;238m";
|
|
28
|
+
const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
|
|
29
|
+
const BG_RESET = "\x1b[49m\x1b[39m";
|
|
22
30
|
|
|
23
31
|
/** The live fields needed by the viewer; historical viewers use a static source. */
|
|
24
32
|
export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
|
|
@@ -29,17 +37,38 @@ export function createStaticConversationSource(
|
|
|
29
37
|
return { messages, subscribe: () => () => {} };
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
type RenderLine = TimelineRenderLine;
|
|
41
|
+
|
|
42
|
+
/** Optional operations are appended so all existing constructor call sites stay valid. */
|
|
43
|
+
export interface ConversationViewerOperations {
|
|
44
|
+
pi: ExtensionAPI;
|
|
45
|
+
ctx: ExtensionContext;
|
|
46
|
+
readOnly?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
export class ConversationViewer implements Component {
|
|
50
|
+
private blocks: ConversationBlock[] = [];
|
|
51
|
+
private showTools = true;
|
|
52
|
+
private selectedMessageIdx = 0;
|
|
53
|
+
private cachedWidth = 0;
|
|
54
|
+
private cachedLines: RenderLine[] = [];
|
|
55
|
+
private cachedMessageStarts: number[] = [];
|
|
56
|
+
private cachedMessageBlocks: ConversationBlock[] = [];
|
|
57
|
+
private cacheVersion = 0;
|
|
58
|
+
private cachedVersion = -1;
|
|
33
59
|
private scrollOffset = 0;
|
|
34
60
|
private autoScroll = true;
|
|
35
61
|
private unsubscribe: (() => void) | undefined;
|
|
36
62
|
private lastInnerW = 0;
|
|
37
63
|
private closed = false;
|
|
38
|
-
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
39
64
|
private stopArmed = false;
|
|
40
65
|
private keys: ViewerKeys;
|
|
41
|
-
/** Steering composer — present while the user is typing a message to the agent. */
|
|
42
66
|
private composer: Input | undefined;
|
|
67
|
+
private searchMode = false;
|
|
68
|
+
private searchQuery = "";
|
|
69
|
+
private searchMatches: ConversationMatch[] = [];
|
|
70
|
+
private currentMatchIdx = -1;
|
|
71
|
+
private timeline: ConversationTimeline;
|
|
43
72
|
|
|
44
73
|
constructor(
|
|
45
74
|
private tui: TUI,
|
|
@@ -48,46 +77,64 @@ export class ConversationViewer implements Component {
|
|
|
48
77
|
private activity: AgentActivity | undefined,
|
|
49
78
|
private theme: Theme,
|
|
50
79
|
private done: (result: undefined) => void,
|
|
51
|
-
/** Abort the agent shown here. Omitted
|
|
80
|
+
/** Abort the agent shown here. Omitted means read-only history. */
|
|
52
81
|
private onStop?: () => void,
|
|
53
|
-
/** User keybindings from
|
|
82
|
+
/** User keybindings from ctx.ui.custom(). */
|
|
54
83
|
keybindings?: ViewerKeybindings,
|
|
55
|
-
/** Send a steering message to the agent.
|
|
84
|
+
/** Send a steering message to the agent. */
|
|
56
85
|
private onSteer?: (message: string) => void,
|
|
86
|
+
/** Optional clipboard/nvim operation context, appended for compatibility. */
|
|
87
|
+
private operations?: ConversationViewerOperations,
|
|
57
88
|
) {
|
|
58
89
|
this.keys = createViewerKeys(keybindings);
|
|
90
|
+
this.blocks = formatConversationMessages(session.messages);
|
|
91
|
+
this.timeline = new ConversationTimeline(tui, theme, {
|
|
92
|
+
cwd: operations?.ctx.cwd,
|
|
93
|
+
record,
|
|
94
|
+
onChange: () => {
|
|
95
|
+
this.cacheVersion++;
|
|
96
|
+
this.invalidate();
|
|
97
|
+
this.tui.requestRender();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
this.timeline.setBlocks(this.blocks);
|
|
59
101
|
this.unsubscribe = session.subscribe(() => {
|
|
60
102
|
if (this.closed) return;
|
|
103
|
+
this.blocks = formatConversationMessages(this.session.messages);
|
|
104
|
+
this.timeline.setBlocks(this.blocks);
|
|
105
|
+
this.cacheVersion++;
|
|
106
|
+
this.invalidate();
|
|
61
107
|
this.tui.requestRender();
|
|
62
108
|
});
|
|
63
109
|
}
|
|
64
110
|
|
|
65
111
|
handleInput(data: string): void {
|
|
66
|
-
// While composing a steer message, the input owns all keys (Enter sends,
|
|
67
|
-
// Esc cancels — both wired in openComposer()). Editing keys flow through.
|
|
68
112
|
if (this.composer) {
|
|
69
113
|
this.composer.handleInput(data);
|
|
70
114
|
this.tui.requestRender();
|
|
71
115
|
return;
|
|
72
116
|
}
|
|
73
|
-
|
|
117
|
+
if (this.searchMode) {
|
|
118
|
+
this.handleSearchInput(data);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
74
121
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
75
122
|
this.closed = true;
|
|
76
123
|
this.done(undefined);
|
|
77
124
|
return;
|
|
78
125
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.
|
|
86
|
-
|
|
126
|
+
if (matchesKey(data, "enter")) {
|
|
127
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
128
|
+
if (this.timeline.isToolFocused()) {
|
|
129
|
+
this.timeline.toggleFocusedTool();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (this.canSteer()) {
|
|
133
|
+
this.stopArmed = false;
|
|
134
|
+
this.openComposer();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
87
137
|
}
|
|
88
|
-
|
|
89
|
-
// Stop/abort the agent (only while it can still be stopped). Two-press:
|
|
90
|
-
// first "x" arms, second confirms — any other key disarms.
|
|
91
138
|
if (matchesKey(data, "x")) {
|
|
92
139
|
if (this.isStoppable()) {
|
|
93
140
|
if (this.stopArmed) {
|
|
@@ -102,62 +149,102 @@ export class ConversationViewer implements Component {
|
|
|
102
149
|
}
|
|
103
150
|
if (this.stopArmed) this.stopArmed = false;
|
|
104
151
|
|
|
105
|
-
|
|
152
|
+
// Input can arrive before the first render in tests and in a freshly-opened overlay.
|
|
153
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
106
154
|
const viewportHeight = this.viewportHeight();
|
|
107
|
-
const maxScroll = Math.max(0,
|
|
155
|
+
const maxScroll = Math.max(0, this.cachedLines.length - viewportHeight);
|
|
108
156
|
|
|
109
|
-
if (
|
|
110
|
-
this.
|
|
111
|
-
this.
|
|
157
|
+
if (data === "t") {
|
|
158
|
+
this.showTools = !this.showTools;
|
|
159
|
+
this.timeline.setShowTools(this.showTools);
|
|
160
|
+
this.invalidate();
|
|
161
|
+
this.tui.requestRender();
|
|
162
|
+
} else if (data === "\t") {
|
|
163
|
+
this.timeline.moveFocus(1);
|
|
164
|
+
this.invalidate();
|
|
165
|
+
this.tui.requestRender();
|
|
166
|
+
} else if (matchesKey(data, Key.shift("tab"))) {
|
|
167
|
+
this.timeline.moveFocus(-1);
|
|
168
|
+
this.invalidate();
|
|
169
|
+
this.tui.requestRender();
|
|
170
|
+
} else if (data === "J" || matchesKey(data, Key.shift("j"))) {
|
|
171
|
+
this.jumpToMessage(1);
|
|
172
|
+
} else if (data === "K" || matchesKey(data, Key.shift("k"))) {
|
|
173
|
+
this.jumpToMessage(-1);
|
|
174
|
+
} else if (data === "g") {
|
|
175
|
+
this.scrollToTop();
|
|
176
|
+
} else if (data === "G" || matchesKey(data, Key.shift("g"))) {
|
|
177
|
+
this.scrollToBottom();
|
|
178
|
+
} else if (data === "/") {
|
|
179
|
+
this.enterSearch();
|
|
180
|
+
} else if (data === "n") {
|
|
181
|
+
this.gotoMatch(1);
|
|
182
|
+
} else if (data === "N") {
|
|
183
|
+
this.gotoMatch(-1);
|
|
184
|
+
} else if (data === "M") {
|
|
185
|
+
void this.copyCurrentMessage();
|
|
186
|
+
} else if (data === "o") {
|
|
187
|
+
void this.viewCurrentInNvim();
|
|
188
|
+
} else if (data === "O" && this.isLive()) {
|
|
189
|
+
void this.editCurrentInNvim();
|
|
190
|
+
} else if (this.keys.scrollUp(data)) {
|
|
191
|
+
this.scrollBy(-1);
|
|
112
192
|
} else if (this.keys.scrollDown(data)) {
|
|
113
|
-
this.
|
|
114
|
-
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
193
|
+
this.scrollBy(1);
|
|
115
194
|
} else if (this.keys.pageUp(data)) {
|
|
116
|
-
this.
|
|
117
|
-
this.autoScroll = false;
|
|
195
|
+
this.scrollBy(-viewportHeight);
|
|
118
196
|
} else if (this.keys.pageDown(data)) {
|
|
119
|
-
this.
|
|
120
|
-
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
197
|
+
this.scrollBy(viewportHeight);
|
|
121
198
|
} else if (matchesKey(data, "home")) {
|
|
122
199
|
this.scrollOffset = 0;
|
|
200
|
+
this.selectedMessageIdx = 0;
|
|
123
201
|
this.autoScroll = false;
|
|
202
|
+
this.tui.requestRender();
|
|
124
203
|
} else if (matchesKey(data, "end")) {
|
|
125
|
-
this.
|
|
126
|
-
this.autoScroll = true;
|
|
204
|
+
this.scrollToBottom();
|
|
127
205
|
}
|
|
206
|
+
|
|
207
|
+
// Keep the computed max alive for the key path even when the cache is empty.
|
|
208
|
+
void maxScroll;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
|
|
212
|
+
if (this.composer || this.searchMode) return undefined;
|
|
213
|
+
const innerW = Math.max(1, this.lastInnerW || event.width - 4);
|
|
214
|
+
const contentLines = this.buildContentLines(innerW);
|
|
215
|
+
const viewportHeight = this.viewportHeight();
|
|
216
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
217
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
218
|
+
const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
|
|
219
|
+
const contentY = event.y - contentOrigin + visibleStart;
|
|
220
|
+
if (contentY < 0 || contentY >= contentLines.length) return undefined;
|
|
221
|
+
const result = this.timeline.handleMouse({ ...event, y: contentY });
|
|
222
|
+
if (result?.handled) {
|
|
223
|
+
this.cacheVersion++;
|
|
224
|
+
this.invalidate();
|
|
225
|
+
this.tui.requestRender();
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
128
228
|
}
|
|
129
229
|
|
|
130
230
|
render(width: number): string[] {
|
|
131
|
-
if (width < 6) return [];
|
|
231
|
+
if (width < 6) return [];
|
|
132
232
|
const th = this.theme;
|
|
133
|
-
const innerW = width - 4;
|
|
233
|
+
const innerW = Math.max(1, width - 4);
|
|
134
234
|
this.lastInnerW = innerW;
|
|
135
235
|
const lines: string[] = [];
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
141
|
-
const row = (content: string) =>
|
|
142
|
-
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
|
|
143
|
-
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
144
|
-
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
236
|
+
const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
237
|
+
const row = (content: string) => th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
|
|
238
|
+
const hrTop = th.fg("border", `╭${"─".repeat(Math.max(0, width - 2))}╮`);
|
|
239
|
+
const hrBot = th.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
145
240
|
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
146
241
|
|
|
147
|
-
// Header
|
|
148
242
|
lines.push(hrTop);
|
|
149
243
|
const name = getDisplayName(this.record.type);
|
|
150
244
|
const modeLabel = getPromptModeLabel(this.record.type);
|
|
151
245
|
const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
|
|
152
|
-
const statusIcon = this.record.status === "running"
|
|
153
|
-
? th.fg("accent", "●")
|
|
154
|
-
: this.record.status === "completed"
|
|
155
|
-
? th.fg("success", "✓")
|
|
156
|
-
: this.record.status === "error"
|
|
157
|
-
? th.fg("error", "✗")
|
|
158
|
-
: th.fg("dim", "○");
|
|
246
|
+
const statusIcon = this.record.status === "running" ? th.fg("accent", "●") : this.record.status === "completed" ? th.fg("success", "✓") : this.record.status === "error" ? th.fg("error", "✗") : th.fg("dim", "○");
|
|
159
247
|
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
|
|
160
|
-
|
|
161
248
|
const headerParts: string[] = [duration];
|
|
162
249
|
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
163
250
|
if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
|
|
@@ -166,206 +253,327 @@ export class ConversationViewer implements Component {
|
|
|
166
253
|
const percent = getSessionContextPercent(this.activity?.session);
|
|
167
254
|
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
168
255
|
}
|
|
169
|
-
|
|
170
|
-
lines.push(row(
|
|
171
|
-
`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
|
|
172
|
-
));
|
|
256
|
+
lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
|
|
173
257
|
const invocationLine = this.invocationLine();
|
|
174
258
|
if (invocationLine) lines.push(row(invocationLine));
|
|
175
259
|
lines.push(hrMid);
|
|
176
260
|
|
|
177
|
-
// Content area — rebuild every render (live data, no cache needed)
|
|
178
261
|
const contentLines = this.buildContentLines(innerW);
|
|
179
262
|
const viewportHeight = this.viewportHeight();
|
|
180
263
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
181
|
-
|
|
182
|
-
if (this.autoScroll) {
|
|
183
|
-
this.scrollOffset = maxScroll;
|
|
184
|
-
}
|
|
185
|
-
|
|
264
|
+
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
186
265
|
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
187
266
|
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
267
|
+
const currentBlockIdx = this.currentBlockGlobalIndex();
|
|
268
|
+
const headerLine = this.activeHeaderLine();
|
|
269
|
+
const showSticky = headerLine >= 0 && headerLine < visibleStart && visible.length > 0;
|
|
270
|
+
const displayed = visible.slice();
|
|
271
|
+
if (showSticky) {
|
|
272
|
+
const block = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
273
|
+
if (block) {
|
|
274
|
+
const header = renderConversationRoleHeader(block, this.theme);
|
|
275
|
+
displayed[0] = { text: ` ${header}`, plain: header, blockIndex: currentBlockIdx, railable: true };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (let i = 0; i < displayed.length; i++) {
|
|
279
|
+
const line = displayed[i];
|
|
280
|
+
let text = line.text;
|
|
281
|
+
if (this.searchMatches.length > 0 && !(showSticky && i === 0)) {
|
|
282
|
+
const hits = this.searchMatches.filter((match) => match.lineIdx === visibleStart + i).map((match) => ({ lineIdx: match.lineIdx, col: match.col, len: match.len, current: this.searchMatches.indexOf(match) === this.currentMatchIdx }));
|
|
283
|
+
if (hits.length > 0) text = highlightConversationLine(text, hits, MATCH_BG, MATCH_BG_CURRENT, BG_RESET);
|
|
284
|
+
}
|
|
285
|
+
if (line.railable && line.blockIndex === currentBlockIdx) {
|
|
286
|
+
text = text.startsWith(" ") ? th.fg("accent", "▎") + text.slice(1) : th.fg("accent", "▎") + text;
|
|
287
|
+
}
|
|
288
|
+
lines.push(row(text));
|
|
191
289
|
}
|
|
290
|
+
for (let i = displayed.length; i < viewportHeight; i++) lines.push(row(""));
|
|
192
291
|
|
|
193
|
-
// Footer
|
|
194
292
|
lines.push(hrMid);
|
|
195
293
|
if (this.composer) {
|
|
196
|
-
// Composer row: the Input renders its own `> ` prompt and cursor.
|
|
197
294
|
lines.push(row(this.composer.render(innerW)[0] ?? ""));
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
295
|
+
const hint = th.fg("dim", "Enter send · Esc cancel");
|
|
296
|
+
const label = th.fg("accent", "✎ steer");
|
|
297
|
+
lines.push(row(label + " ".repeat(Math.max(1, innerW - visibleWidth(label) - visibleWidth(hint))) + hint));
|
|
298
|
+
} else if (this.searchMode) {
|
|
299
|
+
const query = `/${this.searchQuery}`;
|
|
300
|
+
const matchCount = this.searchMatches.length > 0
|
|
301
|
+
? ` · ${this.searchMatches.length} match${this.searchMatches.length === 1 ? "" : "es"}`
|
|
302
|
+
: "";
|
|
303
|
+
const hints = th.fg("dim", " · Enter apply · Esc cancel");
|
|
304
|
+
lines.push(row(truncateToWidth(th.fg("accent", query) + th.fg("dim", matchCount) + hints, innerW, "...", true)));
|
|
202
305
|
} else {
|
|
203
|
-
// Actions on the left, navigation on the right. The scroll hint keeps its
|
|
204
|
-
// full key list so the less-obvious bindings stay discoverable; it leads
|
|
205
|
-
// the right group so "Esc close" is the only part that truncates first.
|
|
206
|
-
const sep = th.fg("dim", " · ");
|
|
207
306
|
const actions: string[] = [];
|
|
208
307
|
if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
|
|
209
|
-
if (this.isStoppable())
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const scrollPct = contentLines.length <= viewportHeight
|
|
217
|
-
? "100%"
|
|
218
|
-
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
308
|
+
if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
|
|
309
|
+
const editHint = this.isLive() ? " O" : "";
|
|
310
|
+
const shortcutHints = [
|
|
311
|
+
`j/k · J/K · g/G t M o${editHint} /n N q`,
|
|
312
|
+
`M o${editHint} /n N q`,
|
|
313
|
+
`M o${editHint} /n N q`,
|
|
314
|
+
];
|
|
315
|
+
const scrollPct = contentLines.length <= viewportHeight ? "100%" : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
219
316
|
const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
317
|
+
const countWidth = visibleWidth(count);
|
|
318
|
+
let status = "";
|
|
319
|
+
for (const hint of shortcutHints) {
|
|
320
|
+
const styledHint = th.fg("dim", hint);
|
|
321
|
+
const withActions = [styledHint, ...actions].join(" ");
|
|
322
|
+
if (visibleWidth(withActions) <= innerW) {
|
|
323
|
+
status = withActions;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
if (visibleWidth(styledHint) <= innerW) status ||= styledHint;
|
|
327
|
+
}
|
|
328
|
+
if (!status) status = th.fg("dim", shortcutHints[shortcutHints.length - 1]);
|
|
329
|
+
const statusWidth = visibleWidth(status);
|
|
330
|
+
const footer = statusWidth + countWidth + 1 <= innerW
|
|
331
|
+
? status + " ".repeat(Math.max(1, innerW - statusWidth - countWidth)) + count
|
|
332
|
+
: status;
|
|
333
|
+
lines.push(row(truncateToWidth(footer, innerW, "...", true)));
|
|
227
334
|
}
|
|
228
335
|
lines.push(hrBot);
|
|
229
|
-
|
|
230
336
|
return lines;
|
|
231
337
|
}
|
|
232
338
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
private canSteer(): boolean {
|
|
240
|
-
return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
|
|
339
|
+
invalidate(): void {
|
|
340
|
+
this.cachedWidth = 0;
|
|
341
|
+
this.cachedLines = [];
|
|
342
|
+
this.cachedMessageStarts = [];
|
|
343
|
+
this.cachedMessageBlocks = [];
|
|
344
|
+
this.cachedVersion = -1;
|
|
241
345
|
}
|
|
242
346
|
|
|
243
|
-
/** Open the inline steering composer and route subsequent input to it. */
|
|
244
|
-
private openComposer(): void {
|
|
245
|
-
const input = new Input();
|
|
246
|
-
input.focused = true;
|
|
247
|
-
input.onSubmit = (value: string) => {
|
|
248
|
-
const message = value.trim();
|
|
249
|
-
this.composer = undefined;
|
|
250
|
-
if (message) this.onSteer?.(message);
|
|
251
|
-
this.tui.requestRender();
|
|
252
|
-
};
|
|
253
|
-
input.onEscape = () => {
|
|
254
|
-
this.composer = undefined;
|
|
255
|
-
this.tui.requestRender();
|
|
256
|
-
};
|
|
257
|
-
this.composer = input;
|
|
258
|
-
this.tui.requestRender();
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
invalidate(): void { /* no cached state to clear */ }
|
|
262
|
-
|
|
263
347
|
dispose(): void {
|
|
264
348
|
this.closed = true;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.unsubscribe = undefined;
|
|
268
|
-
}
|
|
349
|
+
this.unsubscribe?.();
|
|
350
|
+
this.unsubscribe = undefined;
|
|
269
351
|
}
|
|
270
352
|
|
|
271
|
-
// ---- Private ----
|
|
272
|
-
|
|
273
353
|
private viewportHeight(): number {
|
|
274
|
-
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
275
|
-
// more lines than the overlay shows and clip the footer.
|
|
276
354
|
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
277
355
|
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
|
|
278
356
|
}
|
|
279
357
|
|
|
280
358
|
private chromeLines(): number {
|
|
281
|
-
// The composer adds one row above the footer hint while it's open.
|
|
282
359
|
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
|
|
283
360
|
}
|
|
284
361
|
|
|
285
362
|
private invocationLine(): string | undefined {
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
363
|
+
const liveModel = this.isLive() ? this.record.session?.model : undefined;
|
|
364
|
+
const invocation = this.record.invocation
|
|
365
|
+
? {
|
|
366
|
+
...this.record.invocation,
|
|
367
|
+
...(liveModel && { effectiveModelName: liveModel.name ?? liveModel.id }),
|
|
368
|
+
...(this.isLive() && { effectiveThinking: this.record.session?.thinkingLevel }),
|
|
369
|
+
}
|
|
370
|
+
: liveModel || (this.isLive() && this.record.session?.thinkingLevel)
|
|
371
|
+
? {
|
|
372
|
+
effectiveModelName: liveModel?.name ?? liveModel?.id,
|
|
373
|
+
effectiveThinking: this.record.session?.thinkingLevel,
|
|
374
|
+
}
|
|
375
|
+
: undefined;
|
|
376
|
+
if (!invocation) return this.theme.fg("dim", " ↳ model: unknown · thinking: unknown");
|
|
377
|
+
const { modelName, tags } = buildInvocationTags(invocation);
|
|
378
|
+
const effectiveModelName = invocation.effectiveModelName ?? modelName;
|
|
379
|
+
const parts = effectiveModelName ? [`model: ${effectiveModelName}`, ...tags] : ["model: inherit", ...tags];
|
|
289
380
|
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
|
|
290
381
|
}
|
|
291
382
|
|
|
292
|
-
private buildContentLines(width: number):
|
|
383
|
+
private buildContentLines(width: number): RenderLine[] {
|
|
293
384
|
if (width <= 0) return [];
|
|
385
|
+
if (this.cachedVersion === this.cacheVersion && this.cachedWidth === width) return this.cachedLines;
|
|
386
|
+
this.timeline.setShowTools(this.showTools);
|
|
387
|
+
this.timeline.render(width);
|
|
388
|
+
const timelineSnapshot = this.timeline.getSnapshot();
|
|
389
|
+
const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
|
|
390
|
+
this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
|
|
391
|
+
this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
|
|
392
|
+
if (this.record.status === "running" && this.activity) {
|
|
393
|
+
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
394
|
+
const text = `▍ ${act}`;
|
|
395
|
+
lines.push({ text: truncateToWidth(this.theme.fg("accent", "▍ ") + this.theme.fg("dim", act), width), plain: text, blockIndex: -1 });
|
|
396
|
+
}
|
|
397
|
+
this.cachedWidth = width;
|
|
398
|
+
this.cachedVersion = this.cacheVersion;
|
|
399
|
+
this.cachedLines = lines;
|
|
400
|
+
if (this.searchMode || this.searchQuery) this.recomputeMatches();
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
294
403
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
404
|
+
private currentMessageIndex(): number {
|
|
405
|
+
return this.cachedMessageStarts.length === 0 ? 0 : Math.max(0, Math.min(this.selectedMessageIdx, this.cachedMessageStarts.length - 1));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private currentBlockGlobalIndex(): number {
|
|
409
|
+
const block = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
410
|
+
return block ? this.blocks.indexOf(block) : -1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private activeHeaderLine(): number {
|
|
414
|
+
return this.cachedMessageStarts[this.currentMessageIndex()] ?? -1;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private currentBlock(): ConversationBlock | undefined {
|
|
418
|
+
return this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
419
|
+
}
|
|
298
420
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
421
|
+
private messageIndexForOffset(offset: number): number {
|
|
422
|
+
let result = 0;
|
|
423
|
+
for (let i = 0; i < this.cachedMessageStarts.length; i++) {
|
|
424
|
+
if (this.cachedMessageStarts[i] <= offset) result = i;
|
|
425
|
+
else break;
|
|
302
426
|
}
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
303
429
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
needsSeparator = true;
|
|
430
|
+
private jumpToMessage(dir: 1 | -1): void {
|
|
431
|
+
if (this.cachedMessageStarts.length === 0) return;
|
|
432
|
+
const target = this.currentMessageIndex() + dir;
|
|
433
|
+
if (target < 0 || target >= this.cachedMessageStarts.length) return;
|
|
434
|
+
this.selectedMessageIdx = target;
|
|
435
|
+
this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
|
|
436
|
+
this.autoScroll = false;
|
|
437
|
+
this.tui.requestRender();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private scrollBy(delta: number): void {
|
|
441
|
+
const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
442
|
+
this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
|
|
443
|
+
this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
|
|
444
|
+
this.autoScroll = this.scrollOffset >= max;
|
|
445
|
+
this.tui.requestRender();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private scrollToTop(): void {
|
|
449
|
+
this.scrollOffset = 0;
|
|
450
|
+
this.selectedMessageIdx = 0;
|
|
451
|
+
this.autoScroll = false;
|
|
452
|
+
this.tui.requestRender();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private scrollToBottom(): void {
|
|
456
|
+
this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
457
|
+
this.selectedMessageIdx = Math.max(0, this.cachedMessageStarts.length - 1);
|
|
458
|
+
this.autoScroll = true;
|
|
459
|
+
this.tui.requestRender();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private enterSearch(): void {
|
|
463
|
+
this.searchMode = true;
|
|
464
|
+
this.searchQuery = "";
|
|
465
|
+
this.searchMatches = [];
|
|
466
|
+
this.currentMatchIdx = -1;
|
|
467
|
+
this.tui.requestRender();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private handleSearchInput(data: string): void {
|
|
471
|
+
if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) {
|
|
472
|
+
this.searchMode = false;
|
|
473
|
+
this.searchQuery = "";
|
|
474
|
+
this.searchMatches = [];
|
|
475
|
+
this.currentMatchIdx = -1;
|
|
476
|
+
} else if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
|
|
477
|
+
this.searchMode = false;
|
|
478
|
+
if (this.searchMatches.length > 0) this.scrollToMatch(this.currentMatchIdx < 0 ? 0 : this.currentMatchIdx);
|
|
479
|
+
} else if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
|
|
480
|
+
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
481
|
+
this.recomputeMatches();
|
|
482
|
+
} else if (data.length === 1 && data >= " " && data !== "\x7f") {
|
|
483
|
+
this.searchQuery += data;
|
|
484
|
+
this.recomputeMatches();
|
|
360
485
|
}
|
|
486
|
+
this.tui.requestRender();
|
|
487
|
+
}
|
|
361
488
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
489
|
+
private recomputeMatches(): void {
|
|
490
|
+
this.searchMatches = findConversationMatches(this.cachedLines, this.searchQuery);
|
|
491
|
+
this.currentMatchIdx = this.searchMatches.length > 0 ? Math.min(Math.max(this.currentMatchIdx, 0), this.searchMatches.length - 1) : -1;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private gotoMatch(dir: 1 | -1): void {
|
|
495
|
+
if (this.searchMatches.length === 0) return;
|
|
496
|
+
this.currentMatchIdx = this.currentMatchIdx < 0
|
|
497
|
+
? (dir === 1 ? nearestConversationMatchAfter(this.searchMatches, this.scrollOffset) : nearestConversationMatchBefore(this.searchMatches, this.scrollOffset))
|
|
498
|
+
: (this.currentMatchIdx + dir + this.searchMatches.length) % this.searchMatches.length;
|
|
499
|
+
this.scrollToMatch(this.currentMatchIdx);
|
|
500
|
+
this.tui.requestRender();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private scrollToMatch(index: number): void {
|
|
504
|
+
const match = this.searchMatches[index];
|
|
505
|
+
if (!match) return;
|
|
506
|
+
const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
507
|
+
this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
|
|
508
|
+
this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
|
|
509
|
+
this.autoScroll = false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private async copyCurrentMessage(): Promise<void> {
|
|
513
|
+
const block = this.currentBlock();
|
|
514
|
+
if (!block || !this.operations) {
|
|
515
|
+
if (!this.operations) return;
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const source = `${stripAnsi(block.header)}\n${block.copyText}`.trimEnd();
|
|
519
|
+
const clipped = source.length > MAX_COPY_CHARS ? `${source.slice(0, MAX_COPY_CHARS)}\n… [truncated by subagent copy]` : source;
|
|
520
|
+
try {
|
|
521
|
+
await copyToClipboard(clipped);
|
|
522
|
+
this.operations.ctx.ui.notify(`Copied current message (${clipped.length} chars)`, "info");
|
|
523
|
+
} catch (error) {
|
|
524
|
+
this.operations.ctx.ui.notify(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private async viewCurrentInNvim(): Promise<void> {
|
|
529
|
+
const block = this.currentBlock();
|
|
530
|
+
if (!block || !this.operations) return;
|
|
531
|
+
await viewConversationBlockInNvim(this.tui, this.operations.ctx, block);
|
|
532
|
+
this.invalidate();
|
|
533
|
+
this.tui.requestRender(true);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private async editCurrentInNvim(): Promise<void> {
|
|
537
|
+
const block = this.currentBlock();
|
|
538
|
+
if (!block || !this.operations || !this.isLive() || block.role !== "assistant") return;
|
|
539
|
+
const sent = await editLiveAssistantBlockInNvim(this.operations.pi, this.tui, this.operations.ctx, block);
|
|
540
|
+
if (sent) this.done(undefined);
|
|
541
|
+
else {
|
|
542
|
+
this.invalidate();
|
|
543
|
+
this.tui.requestRender(true);
|
|
367
544
|
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private isReadOnly(): boolean {
|
|
548
|
+
return this.operations?.readOnly === true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private isLive(): boolean {
|
|
552
|
+
return !this.isReadOnly() && this.record.session !== undefined;
|
|
553
|
+
}
|
|
368
554
|
|
|
369
|
-
|
|
555
|
+
private isStoppable(): boolean {
|
|
556
|
+
return !this.isReadOnly() && !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private canSteer(): boolean {
|
|
560
|
+
return !this.isReadOnly() && !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private openComposer(): void {
|
|
564
|
+
const input = new Input();
|
|
565
|
+
input.focused = true;
|
|
566
|
+
input.onSubmit = (value: string) => {
|
|
567
|
+
const message = value.trim();
|
|
568
|
+
this.composer = undefined;
|
|
569
|
+
if (message) this.onSteer?.(message);
|
|
570
|
+
this.tui.requestRender();
|
|
571
|
+
};
|
|
572
|
+
input.onEscape = () => {
|
|
573
|
+
this.composer = undefined;
|
|
574
|
+
this.tui.requestRender();
|
|
575
|
+
};
|
|
576
|
+
this.composer = input;
|
|
577
|
+
this.tui.requestRender();
|
|
370
578
|
}
|
|
371
579
|
}
|