@yeshwanthyk/coding-agent 0.2.2
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 +40 -0
- package/README.md +372 -0
- package/package.json +42 -0
- package/src/adapters/acp/index.ts +305 -0
- package/src/adapters/acp/protocol.ts +191 -0
- package/src/adapters/acp/session.ts +289 -0
- package/src/adapters/acp/updates.ts +96 -0
- package/src/adapters/cli/headless.ts +112 -0
- package/src/adapters/cli/validate.ts +50 -0
- package/src/adapters/tui/app.tsx +39 -0
- package/src/agent-events.ts +671 -0
- package/src/args.ts +102 -0
- package/src/autocomplete-commands.ts +102 -0
- package/src/commands.ts +23 -0
- package/src/compact-handler.ts +272 -0
- package/src/components/Footer.tsx +49 -0
- package/src/components/Header.tsx +218 -0
- package/src/components/MessageList.tsx +380 -0
- package/src/config.ts +1 -0
- package/src/domain/commands/builtin/clear.ts +14 -0
- package/src/domain/commands/builtin/compact.ts +96 -0
- package/src/domain/commands/builtin/conceal.ts +9 -0
- package/src/domain/commands/builtin/diffwrap.ts +9 -0
- package/src/domain/commands/builtin/editor.ts +24 -0
- package/src/domain/commands/builtin/exit.ts +14 -0
- package/src/domain/commands/builtin/followup.ts +24 -0
- package/src/domain/commands/builtin/index.ts +29 -0
- package/src/domain/commands/builtin/login.ts +118 -0
- package/src/domain/commands/builtin/model.ts +66 -0
- package/src/domain/commands/builtin/status.ts +32 -0
- package/src/domain/commands/builtin/steer.ts +24 -0
- package/src/domain/commands/builtin/theme.ts +23 -0
- package/src/domain/commands/builtin/thinking.ts +16 -0
- package/src/domain/commands/helpers.ts +41 -0
- package/src/domain/commands/registry.ts +42 -0
- package/src/domain/commands/types.ts +69 -0
- package/src/domain/messaging/content.ts +117 -0
- package/src/editor.ts +103 -0
- package/src/extensibility/schema.ts +1 -0
- package/src/extensibility/validation.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAgentEvents.ts +28 -0
- package/src/hooks/useEditorBridge.ts +101 -0
- package/src/hooks/useGitStatus.ts +28 -0
- package/src/hooks/usePromptQueue.ts +7 -0
- package/src/hooks/useSessionController.ts +5 -0
- package/src/hooks/useSpinner.ts +28 -0
- package/src/hooks/useToastManager.ts +26 -0
- package/src/index.ts +188 -0
- package/src/keyboard-handler.ts +134 -0
- package/src/profiler.ts +40 -0
- package/src/runtime/context.tsx +16 -0
- package/src/runtime/factory.ts +63 -0
- package/src/runtime/git/git-info.ts +25 -0
- package/src/runtime/session/session-controller.ts +208 -0
- package/src/session-manager.ts +1 -0
- package/src/session-picker.tsx +134 -0
- package/src/shell-runner.ts +134 -0
- package/src/syntax-highlighting.ts +114 -0
- package/src/theme-names.ts +37 -0
- package/src/tool-ui-contracts.ts +77 -0
- package/src/tui-open-rendering.tsx +565 -0
- package/src/types.ts +89 -0
- package/src/ui/app-shell/TuiApp.tsx +586 -0
- package/src/ui/clipboard/osc52.ts +18 -0
- package/src/ui/components/modals/ConfirmModal.tsx +52 -0
- package/src/ui/components/modals/EditorModal.tsx +39 -0
- package/src/ui/components/modals/InputModal.tsx +30 -0
- package/src/ui/components/modals/ModalContainer.tsx +67 -0
- package/src/ui/components/modals/SelectModal.tsx +48 -0
- package/src/ui/components/modals/index.ts +4 -0
- package/src/ui/features/composer/Composer.tsx +73 -0
- package/src/ui/features/composer/SlashCommandHandler.ts +58 -0
- package/src/ui/features/composer/keyboard.ts +3 -0
- package/src/ui/features/main-view/MainView.tsx +367 -0
- package/src/ui/features/message-pane/MessagePane.tsx +34 -0
- package/src/ui/hooks/useModals.ts +74 -0
- package/src/ui/state/app-store.ts +67 -0
- package/src/utils.ts +14 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard event handling for TUI application
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { KeyEvent } from "@opentui/core"
|
|
6
|
+
|
|
7
|
+
export interface KeyboardHandlerConfig {
|
|
8
|
+
// Autocomplete state getters
|
|
9
|
+
showAutocomplete: () => boolean
|
|
10
|
+
autocompleteItems: () => Array<{ label: string; description?: string }>
|
|
11
|
+
setAutocompleteIndex: (updater: (i: number) => number) => void
|
|
12
|
+
setShowAutocomplete: (v: boolean) => void
|
|
13
|
+
applyAutocomplete: () => boolean
|
|
14
|
+
|
|
15
|
+
// Responding state (getters for reactivity)
|
|
16
|
+
isResponding: () => boolean
|
|
17
|
+
retryStatus: () => string | null
|
|
18
|
+
|
|
19
|
+
// Actions
|
|
20
|
+
onAbort: () => string | null
|
|
21
|
+
onToggleThinking: () => void
|
|
22
|
+
onCycleModel: () => void
|
|
23
|
+
onCycleThinking: () => void
|
|
24
|
+
toggleLastToolExpanded: () => void
|
|
25
|
+
copySelectionToClipboard: () => void
|
|
26
|
+
|
|
27
|
+
// Editor control
|
|
28
|
+
clearEditor: () => void
|
|
29
|
+
setEditorText: (text: string) => void
|
|
30
|
+
|
|
31
|
+
// Ctrl+C timing
|
|
32
|
+
lastCtrlC: { current: number }
|
|
33
|
+
|
|
34
|
+
// Exit handler (must be called for proper cleanup)
|
|
35
|
+
onExit: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createKeyboardHandler(config: KeyboardHandlerConfig): (e: KeyEvent) => void {
|
|
39
|
+
return (e: KeyEvent) => {
|
|
40
|
+
// Autocomplete navigation
|
|
41
|
+
if (config.showAutocomplete()) {
|
|
42
|
+
const items = config.autocompleteItems()
|
|
43
|
+
if (e.name === "up") {
|
|
44
|
+
config.setAutocompleteIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (e.name === "down") {
|
|
49
|
+
config.setAutocompleteIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
|
50
|
+
e.preventDefault()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (e.name === "tab" || e.name === "return") {
|
|
54
|
+
if (config.applyAutocomplete()) {
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (e.name === "escape") {
|
|
60
|
+
config.setShowAutocomplete(false)
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Ctrl+N/Ctrl+P for autocomplete navigation
|
|
67
|
+
if (config.showAutocomplete() && e.ctrl && (e.name === "n" || e.name === "p")) {
|
|
68
|
+
const items = config.autocompleteItems()
|
|
69
|
+
if (e.name === "n") {
|
|
70
|
+
config.setAutocompleteIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
|
71
|
+
} else {
|
|
72
|
+
config.setAutocompleteIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
|
73
|
+
}
|
|
74
|
+
e.preventDefault()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Ctrl+C - clear or exit
|
|
79
|
+
if (e.ctrl && e.name === "c") {
|
|
80
|
+
const now = Date.now()
|
|
81
|
+
if (now - config.lastCtrlC.current < 750) {
|
|
82
|
+
config.onExit()
|
|
83
|
+
} else {
|
|
84
|
+
config.clearEditor()
|
|
85
|
+
}
|
|
86
|
+
config.lastCtrlC.current = now
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Escape - abort if responding
|
|
92
|
+
if (e.name === "escape" && (config.isResponding() || config.retryStatus())) {
|
|
93
|
+
const restore = config.onAbort()
|
|
94
|
+
if (restore) config.setEditorText(restore)
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Ctrl+O - toggle latest tool block
|
|
100
|
+
if (e.ctrl && e.name === "o") {
|
|
101
|
+
config.toggleLastToolExpanded()
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Ctrl+T - toggle thinking visibility
|
|
107
|
+
if (e.ctrl && e.name === "t") {
|
|
108
|
+
config.onToggleThinking()
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Ctrl+P - cycle model
|
|
114
|
+
if (e.ctrl && e.name === "p") {
|
|
115
|
+
config.onCycleModel()
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ctrl+Y - copy selection to clipboard
|
|
121
|
+
if (e.ctrl && e.name === "y") {
|
|
122
|
+
config.copySelectionToClipboard()
|
|
123
|
+
e.preventDefault()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Shift+Tab - cycle thinking level
|
|
128
|
+
if (e.shift && e.name === "tab") {
|
|
129
|
+
config.onCycleThinking()
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/profiler.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks"
|
|
2
|
+
|
|
3
|
+
type Stat = { count: number; totalMs: number; maxMs: number }
|
|
4
|
+
|
|
5
|
+
const enabled = process.env["MARVIN_TUI_PROFILE"] === "1"
|
|
6
|
+
const reportEveryMs = 2000
|
|
7
|
+
const stats = new Map<string, Stat>()
|
|
8
|
+
let lastReport = performance.now()
|
|
9
|
+
|
|
10
|
+
function record(name: string, ms: number): void {
|
|
11
|
+
if (!enabled) return
|
|
12
|
+
const stat = stats.get(name) ?? { count: 0, totalMs: 0, maxMs: 0 }
|
|
13
|
+
stat.count += 1
|
|
14
|
+
stat.totalMs += ms
|
|
15
|
+
if (ms > stat.maxMs) stat.maxMs = ms
|
|
16
|
+
stats.set(name, stat)
|
|
17
|
+
|
|
18
|
+
const now = performance.now()
|
|
19
|
+
if (now - lastReport < reportEveryMs) return
|
|
20
|
+
lastReport = now
|
|
21
|
+
try {
|
|
22
|
+
for (const [key, s] of stats) {
|
|
23
|
+
const avg = s.totalMs / Math.max(1, s.count)
|
|
24
|
+
process.stderr.write(`[perf] ${key} avg=${avg.toFixed(1)}ms max=${s.maxMs.toFixed(1)}ms n=${s.count}\n`)
|
|
25
|
+
s.count = 0
|
|
26
|
+
s.totalMs = 0
|
|
27
|
+
s.maxMs = 0
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function profile<T>(name: string, fn: () => T): T {
|
|
33
|
+
if (!enabled) return fn()
|
|
34
|
+
const start = performance.now()
|
|
35
|
+
try {
|
|
36
|
+
return fn()
|
|
37
|
+
} finally {
|
|
38
|
+
record(name, performance.now() - start)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext, useContext, type JSX } from "solid-js"
|
|
2
|
+
import type { RuntimeContext as RuntimeServices } from "./factory.js"
|
|
3
|
+
|
|
4
|
+
const RuntimeContext = createContext<RuntimeServices | null>(null)
|
|
5
|
+
|
|
6
|
+
export const RuntimeProvider = (props: { runtime: RuntimeServices; children: JSX.Element }) => (
|
|
7
|
+
<RuntimeContext.Provider value={props.runtime}>{props.children}</RuntimeContext.Provider>
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
export const useRuntime = (): RuntimeServices => {
|
|
11
|
+
const ctx = useContext(RuntimeContext)
|
|
12
|
+
if (!ctx) {
|
|
13
|
+
throw new Error("RuntimeContext not found")
|
|
14
|
+
}
|
|
15
|
+
return ctx
|
|
16
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Context, Effect, Exit, Layer, Scope } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
RuntimeLayer,
|
|
4
|
+
RuntimeServicesTag,
|
|
5
|
+
type AdapterKind,
|
|
6
|
+
type RuntimeLayerOptions,
|
|
7
|
+
type RuntimeServices,
|
|
8
|
+
} from "@yeshwanthyk/runtime-effect/runtime.js";
|
|
9
|
+
import type { LoadConfigOptions } from "@yeshwanthyk/runtime-effect/config.js";
|
|
10
|
+
|
|
11
|
+
export type RuntimeInitArgs = LoadConfigOptions;
|
|
12
|
+
|
|
13
|
+
export type RuntimeContext = RuntimeServices & {
|
|
14
|
+
/**
|
|
15
|
+
* Explicitly shuts down the runtime scope, allowing services like the LSP
|
|
16
|
+
* manager to flush state before the process exits.
|
|
17
|
+
*/
|
|
18
|
+
close: () => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const toLayerOptions = (args: RuntimeInitArgs | undefined, adapter: AdapterKind): RuntimeLayerOptions => ({
|
|
22
|
+
adapter,
|
|
23
|
+
configDir: args?.configDir,
|
|
24
|
+
configPath: args?.configPath,
|
|
25
|
+
provider: args?.provider,
|
|
26
|
+
model: args?.model,
|
|
27
|
+
thinking: args?.thinking,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const createRuntime = async (
|
|
31
|
+
args: RuntimeInitArgs = {},
|
|
32
|
+
adapter: AdapterKind = "tui",
|
|
33
|
+
): Promise<RuntimeContext> => {
|
|
34
|
+
const layer = RuntimeLayer(toLayerOptions(args, adapter)) as Layer.Layer<RuntimeServices, never, never>;
|
|
35
|
+
|
|
36
|
+
const setupEffect = Effect.gen(function* () {
|
|
37
|
+
const scope = yield* Scope.make();
|
|
38
|
+
const context = yield* Layer.buildWithScope(layer, scope);
|
|
39
|
+
const services = Context.get(context, RuntimeServicesTag);
|
|
40
|
+
return { services, scope };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { services, scope } = await Effect.runPromise(setupEffect);
|
|
44
|
+
|
|
45
|
+
let closed = false;
|
|
46
|
+
const close = async () => {
|
|
47
|
+
if (closed) return;
|
|
48
|
+
closed = true;
|
|
49
|
+
process.removeListener("exit", exitHandler);
|
|
50
|
+
await Effect.runPromise(Scope.close(scope, Exit.void));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const exitHandler = () => {
|
|
54
|
+
void close();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
process.once("exit", exitHandler);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...services,
|
|
61
|
+
close,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs"
|
|
2
|
+
import { dirname, join } from "path"
|
|
3
|
+
|
|
4
|
+
export const findGitHeadPath = (startDir: string = process.cwd()): string | null => {
|
|
5
|
+
let dir = startDir
|
|
6
|
+
while (true) {
|
|
7
|
+
const gitHeadPath = join(dir, ".git", "HEAD")
|
|
8
|
+
if (existsSync(gitHeadPath)) return gitHeadPath
|
|
9
|
+
const parent = dirname(dir)
|
|
10
|
+
if (parent === dir) return null
|
|
11
|
+
dir = parent
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const getCurrentBranch = (startDir?: string): string | null => {
|
|
16
|
+
try {
|
|
17
|
+
const gitHeadPath = findGitHeadPath(startDir)
|
|
18
|
+
if (!gitHeadPath) return null
|
|
19
|
+
const content = readFileSync(gitHeadPath, "utf8").trim()
|
|
20
|
+
if (content.startsWith("ref: refs/heads/")) return content.slice(16)
|
|
21
|
+
return "detached"
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Agent, ThinkingLevel, AppMessage } from "@yeshwanthyk/agent-core"
|
|
2
|
+
import type { Api, Model, KnownProvider } from "@yeshwanthyk/ai"
|
|
3
|
+
import type { HookRunner } from "../../hooks/index.js"
|
|
4
|
+
import type { SessionManager, LoadedSession } from "../../session-manager.js"
|
|
5
|
+
import type { UIMessage, ToolBlock, UIContentBlock, UIShellMessage } from "../../types.js"
|
|
6
|
+
import {
|
|
7
|
+
extractOrderedBlocks,
|
|
8
|
+
extractThinking,
|
|
9
|
+
extractToolCalls,
|
|
10
|
+
extractText,
|
|
11
|
+
getEditDiffText,
|
|
12
|
+
getToolText,
|
|
13
|
+
} from "@domain/messaging/content.js"
|
|
14
|
+
import { resolveProvider, resolveModel } from "@domain/commands/helpers.js"
|
|
15
|
+
import type { PromptDeliveryMode, PromptQueue } from "@yeshwanthyk/runtime-effect/session/prompt-queue.js"
|
|
16
|
+
|
|
17
|
+
export interface SessionControllerOptions {
|
|
18
|
+
initialProvider: KnownProvider
|
|
19
|
+
initialModel: Model<Api>
|
|
20
|
+
initialModelId: string
|
|
21
|
+
initialThinking: ThinkingLevel
|
|
22
|
+
agent: Agent
|
|
23
|
+
sessionManager: SessionManager
|
|
24
|
+
hookRunner: HookRunner
|
|
25
|
+
toolByName: Map<string, { label: string; source: "builtin" | "custom"; sourcePath?: string; renderCall?: any; renderResult?: any }>
|
|
26
|
+
setMessages: (updater: (prev: UIMessage[]) => UIMessage[]) => void
|
|
27
|
+
setContextTokens: (v: number) => void
|
|
28
|
+
setDisplayProvider: (provider: KnownProvider) => void
|
|
29
|
+
setDisplayModelId: (id: string) => void
|
|
30
|
+
setDisplayThinking: (v: ThinkingLevel) => void
|
|
31
|
+
setDisplayContextWindow: (v: number) => void
|
|
32
|
+
shellInjectionPrefix: string
|
|
33
|
+
promptQueue?: PromptQueue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionControllerState {
|
|
37
|
+
ensureSession: () => void
|
|
38
|
+
restoreSession: (session: LoadedSession) => void
|
|
39
|
+
currentProvider: () => KnownProvider
|
|
40
|
+
setCurrentProvider: (p: KnownProvider) => void
|
|
41
|
+
currentModelId: () => string
|
|
42
|
+
setCurrentModelId: (id: string) => void
|
|
43
|
+
currentThinking: () => ThinkingLevel
|
|
44
|
+
setCurrentThinking: (t: ThinkingLevel) => void
|
|
45
|
+
isSessionStarted: () => boolean
|
|
46
|
+
followUp: (text: string) => Promise<void>
|
|
47
|
+
steer: (text: string) => Promise<void>
|
|
48
|
+
sendUserMessage: (text: string, options?: { deliverAs?: PromptDeliveryMode }) => Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSessionController(options: SessionControllerOptions): SessionControllerState {
|
|
52
|
+
let sessionStarted = false
|
|
53
|
+
let currentProvider = options.initialProvider
|
|
54
|
+
let currentModelId = options.initialModelId
|
|
55
|
+
let currentThinking = options.initialThinking
|
|
56
|
+
|
|
57
|
+
options.setDisplayProvider(currentProvider)
|
|
58
|
+
|
|
59
|
+
const ensureSession = () => {
|
|
60
|
+
if (!sessionStarted) {
|
|
61
|
+
options.sessionManager.startSession(currentProvider, currentModelId, currentThinking)
|
|
62
|
+
sessionStarted = true
|
|
63
|
+
void options.hookRunner.emit({ type: "session.start", sessionId: options.sessionManager.sessionId })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const restoreSession = (session: LoadedSession) => {
|
|
68
|
+
const { metadata } = session
|
|
69
|
+
const sessionMessages = session.messages as AppMessage[]
|
|
70
|
+
const resolvedProvider = resolveProvider(metadata.provider)
|
|
71
|
+
if (resolvedProvider) {
|
|
72
|
+
const resolvedModel = resolveModel(resolvedProvider, metadata.modelId)
|
|
73
|
+
if (resolvedModel) {
|
|
74
|
+
currentProvider = resolvedProvider
|
|
75
|
+
currentModelId = resolvedModel.id
|
|
76
|
+
currentThinking = metadata.thinkingLevel
|
|
77
|
+
options.setDisplayProvider(resolvedProvider)
|
|
78
|
+
options.agent.setModel(resolvedModel)
|
|
79
|
+
options.agent.setThinkingLevel(metadata.thinkingLevel)
|
|
80
|
+
options.setDisplayModelId(resolvedModel.id)
|
|
81
|
+
options.setDisplayThinking(metadata.thinkingLevel)
|
|
82
|
+
options.setDisplayContextWindow(resolvedModel.contextWindow)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
options.agent.replaceMessages(sessionMessages)
|
|
86
|
+
|
|
87
|
+
for (let i = sessionMessages.length - 1; i >= 0; i--) {
|
|
88
|
+
const msg = sessionMessages[i] as { role: string; usage?: { totalTokens?: number } }
|
|
89
|
+
if (msg.role === "assistant" && msg.usage?.totalTokens) {
|
|
90
|
+
options.setContextTokens(msg.usage.totalTokens)
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const toolResultMap = new Map<string, { output: string; editDiff: string | null; isError: boolean }>()
|
|
96
|
+
for (const msg of sessionMessages) {
|
|
97
|
+
if (msg.role === "toolResult") {
|
|
98
|
+
toolResultMap.set(msg.toolCallId, {
|
|
99
|
+
output: getToolText(msg),
|
|
100
|
+
editDiff: getEditDiffText(msg),
|
|
101
|
+
isError: msg.isError ?? false,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const uiMessages: UIMessage[] = []
|
|
107
|
+
for (const msg of sessionMessages) {
|
|
108
|
+
if (msg.role === "user") {
|
|
109
|
+
const contentText = typeof msg.content === "string" ? msg.content : extractText(msg.content as unknown[])
|
|
110
|
+
if (contentText.startsWith(options.shellInjectionPrefix)) continue
|
|
111
|
+
uiMessages.push({ id: crypto.randomUUID(), role: "user", content: contentText })
|
|
112
|
+
} else if (msg.role === "assistant") {
|
|
113
|
+
const text = extractText(msg.content as unknown[])
|
|
114
|
+
const thinking = extractThinking(msg.content as unknown[])
|
|
115
|
+
const toolCalls = extractToolCalls(msg.content as unknown[])
|
|
116
|
+
const tools: ToolBlock[] = toolCalls.map((tc) => {
|
|
117
|
+
const result = toolResultMap.get(tc.id)
|
|
118
|
+
const meta = options.toolByName.get(tc.name)
|
|
119
|
+
return {
|
|
120
|
+
id: tc.id,
|
|
121
|
+
name: tc.name,
|
|
122
|
+
args: tc.args,
|
|
123
|
+
output: result?.output,
|
|
124
|
+
editDiff: result?.editDiff || undefined,
|
|
125
|
+
isError: result?.isError ?? false,
|
|
126
|
+
isComplete: true,
|
|
127
|
+
label: meta?.label,
|
|
128
|
+
source: meta?.source,
|
|
129
|
+
sourcePath: meta?.sourcePath,
|
|
130
|
+
renderCall: meta?.renderCall,
|
|
131
|
+
renderResult: meta?.renderResult,
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
const orderedBlocks = extractOrderedBlocks(msg.content as unknown[])
|
|
135
|
+
const contentBlocks: UIContentBlock[] = orderedBlocks.map((block) => {
|
|
136
|
+
if (block.type === "thinking") {
|
|
137
|
+
return { type: "thinking", id: block.id, summary: block.summary, preview: block.preview, full: block.full }
|
|
138
|
+
} else if (block.type === "text") {
|
|
139
|
+
return { type: "text", text: block.text }
|
|
140
|
+
} else {
|
|
141
|
+
const tool = tools.find((t) => t.id === block.id)
|
|
142
|
+
return {
|
|
143
|
+
type: "tool",
|
|
144
|
+
tool: tool || { id: block.id, name: block.name, args: block.args, isError: false, isComplete: false },
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
uiMessages.push({ id: crypto.randomUUID(), role: "assistant", content: text, thinking: thinking || undefined, isStreaming: false, tools, contentBlocks })
|
|
149
|
+
} else if ((msg as { role: string }).role === "shell") {
|
|
150
|
+
const shellMsg = msg as unknown as UIShellMessage
|
|
151
|
+
uiMessages.push({
|
|
152
|
+
id: crypto.randomUUID(),
|
|
153
|
+
role: "shell",
|
|
154
|
+
command: shellMsg.command,
|
|
155
|
+
output: shellMsg.output,
|
|
156
|
+
exitCode: shellMsg.exitCode,
|
|
157
|
+
truncated: shellMsg.truncated,
|
|
158
|
+
tempFilePath: shellMsg.tempFilePath,
|
|
159
|
+
timestamp: shellMsg.timestamp,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
options.setMessages(() => uiMessages)
|
|
165
|
+
const sessionPath = options.sessionManager.listSessions().find((s) => s.id === metadata.id)?.path || ""
|
|
166
|
+
options.sessionManager.continueSession(sessionPath, metadata.id)
|
|
167
|
+
sessionStarted = true
|
|
168
|
+
void options.hookRunner.emit({ type: "session.resume", sessionId: metadata.id })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const queueUserMessage = async (text: string, mode: PromptDeliveryMode) => {
|
|
172
|
+
const trimmed = text
|
|
173
|
+
if (!trimmed) return
|
|
174
|
+
const message: AppMessage = {
|
|
175
|
+
role: "user",
|
|
176
|
+
content: [{ type: "text", text: trimmed }],
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
}
|
|
179
|
+
options.promptQueue?.push({ text: trimmed, mode })
|
|
180
|
+
if (mode === "steer") {
|
|
181
|
+
await options.agent.steer(message)
|
|
182
|
+
} else {
|
|
183
|
+
await options.agent.followUp(message)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
ensureSession,
|
|
189
|
+
restoreSession,
|
|
190
|
+
currentProvider: () => currentProvider,
|
|
191
|
+
setCurrentProvider: (p) => {
|
|
192
|
+
currentProvider = p
|
|
193
|
+
options.setDisplayProvider(p)
|
|
194
|
+
},
|
|
195
|
+
currentModelId: () => currentModelId,
|
|
196
|
+
setCurrentModelId: (id) => {
|
|
197
|
+
currentModelId = id
|
|
198
|
+
},
|
|
199
|
+
currentThinking: () => currentThinking,
|
|
200
|
+
setCurrentThinking: (t) => {
|
|
201
|
+
currentThinking = t
|
|
202
|
+
},
|
|
203
|
+
isSessionStarted: () => sessionStarted,
|
|
204
|
+
followUp: (text) => queueUserMessage(text, "followUp"),
|
|
205
|
+
steer: (text) => queueUserMessage(text, "steer"),
|
|
206
|
+
sendUserMessage: (text, options) => queueUserMessage(text, options?.deliverAs ?? "followUp"),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@yeshwanthyk/runtime-effect/session-manager";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTUI-based session picker
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { render, useTerminalDimensions, useKeyboard } from "@opentui/solid"
|
|
6
|
+
import { SelectList, ThemeProvider, useTheme, type SelectItem, type SelectListRef, type ThemeMode } from "@yeshwanthyk/open-tui"
|
|
7
|
+
import type { SessionManager } from "./session-manager.js"
|
|
8
|
+
|
|
9
|
+
/** Detect system dark/light mode (macOS only, defaults to dark) */
|
|
10
|
+
function detectThemeMode(): ThemeMode {
|
|
11
|
+
try {
|
|
12
|
+
const result = Bun.spawnSync(["defaults", "read", "-g", "AppleInterfaceStyle"])
|
|
13
|
+
return result.stdout.toString().trim().toLowerCase() === "dark" ? "dark" : "light"
|
|
14
|
+
} catch {
|
|
15
|
+
return "dark"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SessionPickerProps {
|
|
20
|
+
sessions: Array<{
|
|
21
|
+
path: string
|
|
22
|
+
firstMessage: string
|
|
23
|
+
timestamp: number
|
|
24
|
+
messageCount: number
|
|
25
|
+
modelId: string
|
|
26
|
+
}>
|
|
27
|
+
onSelect: (path: string) => void
|
|
28
|
+
onCancel: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function SessionPickerApp(props: SessionPickerProps) {
|
|
32
|
+
const { theme } = useTheme()
|
|
33
|
+
const dimensions = useTerminalDimensions()
|
|
34
|
+
let listRef: SelectListRef | undefined
|
|
35
|
+
|
|
36
|
+
const items: SelectItem[] = props.sessions.map((s) => ({
|
|
37
|
+
value: s.path,
|
|
38
|
+
label: formatFirstMessage(s.firstMessage),
|
|
39
|
+
description: formatMeta(s.timestamp, s.messageCount, s.modelId),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
useKeyboard((e: { name: string; ctrl?: boolean }) => {
|
|
43
|
+
if (e.name === "up" || (e.ctrl && e.name === "p")) {
|
|
44
|
+
listRef?.moveUp()
|
|
45
|
+
} else if (e.name === "down" || (e.ctrl && e.name === "n")) {
|
|
46
|
+
listRef?.moveDown()
|
|
47
|
+
} else if (e.name === "return") {
|
|
48
|
+
listRef?.select()
|
|
49
|
+
} else if (e.name === "escape" || (e.ctrl && e.name === "c")) {
|
|
50
|
+
props.onCancel()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box
|
|
56
|
+
flexDirection="column"
|
|
57
|
+
width={dimensions().width}
|
|
58
|
+
height={dimensions().height}
|
|
59
|
+
>
|
|
60
|
+
<text fg={theme.textMuted}>Resume Session</text>
|
|
61
|
+
<box height={1} />
|
|
62
|
+
<SelectList
|
|
63
|
+
ref={(r) => { listRef = r }}
|
|
64
|
+
items={items}
|
|
65
|
+
maxVisible={Math.min(10, dimensions().height - 4)}
|
|
66
|
+
width={dimensions().width - 2}
|
|
67
|
+
onSelect={(item) => props.onSelect(item.value)}
|
|
68
|
+
onCancel={props.onCancel}
|
|
69
|
+
/>
|
|
70
|
+
<box flexGrow={1} />
|
|
71
|
+
<text fg={theme.textMuted}>↑/↓ navigate · Enter select · Esc cancel</text>
|
|
72
|
+
</box>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatFirstMessage(msg: string): string {
|
|
77
|
+
return msg.replace(/\n/g, " ").slice(0, 60)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatMeta(ts: number, count: number, model: string): string {
|
|
81
|
+
const ago = formatRelativeTime(ts)
|
|
82
|
+
return `${ago} · ${count} msgs · ${model}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatRelativeTime(ts: number): string {
|
|
86
|
+
const seconds = Math.floor((Date.now() - ts) / 1000)
|
|
87
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
88
|
+
const minutes = Math.floor(seconds / 60)
|
|
89
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
90
|
+
const hours = Math.floor(minutes / 60)
|
|
91
|
+
if (hours < 24) return `${hours}h ago`
|
|
92
|
+
const days = Math.floor(hours / 24)
|
|
93
|
+
return `${days}d ago`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function selectSession(sessionManager: SessionManager): Promise<string | null> {
|
|
97
|
+
const sessions = sessionManager.loadAllSessions()
|
|
98
|
+
if (sessions.length === 0) return null
|
|
99
|
+
if (sessions.length === 1) return sessions[0]!.path
|
|
100
|
+
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
let resolved = false
|
|
103
|
+
|
|
104
|
+
const doResolve = (value: string | null) => {
|
|
105
|
+
if (resolved) return
|
|
106
|
+
resolved = true
|
|
107
|
+
if (value === null) {
|
|
108
|
+
// Cancel - exit immediately since we're done
|
|
109
|
+
process.stdout.write("\nNo session selected\n")
|
|
110
|
+
process.exit(0)
|
|
111
|
+
}
|
|
112
|
+
resolve(value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const themeMode = detectThemeMode()
|
|
116
|
+
|
|
117
|
+
render(
|
|
118
|
+
() => (
|
|
119
|
+
<ThemeProvider mode={themeMode}>
|
|
120
|
+
<SessionPickerApp
|
|
121
|
+
sessions={sessions}
|
|
122
|
+
onSelect={(path) => doResolve(path)}
|
|
123
|
+
onCancel={() => doResolve(null)}
|
|
124
|
+
/>
|
|
125
|
+
</ThemeProvider>
|
|
126
|
+
),
|
|
127
|
+
{
|
|
128
|
+
targetFps: 30,
|
|
129
|
+
exitOnCtrlC: false,
|
|
130
|
+
useKittyKeyboard: {},
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
}
|