opencode-summary 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +139 -2
- package/package.json +45 -4
- package/src/lib.ts +135 -0
- package/src/prompt.ts +12 -0
- package/src/tui.tsx +558 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 frostehhh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,140 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-summary
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/frostehhh/opencode-summary/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
TUI plugin for [opencode](https://opencode.ai) V2. `/summary` opens a modal that generates and shows a summary of the
|
|
7
|
+
current session using `session.generate` (the session's current model; the conversation history is not changed). The
|
|
8
|
+
summary is stored in session metadata (`metadata["opencode-summary"]`), and a footer indicator shows how many turns ago
|
|
9
|
+
it was made.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Add the plugin to your TUI config (`cli.json`), since it is a TUI-only plugin:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{ "plugins": ["github:frostehhh/opencode-summary#v0.1.0"] }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it with the CLI:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
opencode plugin add github:frostehhh/opencode-summary#v0.1.0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Replace `v0.1.0` with the [release](https://github.com/frostehhh/opencode-summary/releases) you want. Omit the `#tag`
|
|
26
|
+
to follow the default branch. Restart opencode afterwards.
|
|
27
|
+
|
|
28
|
+
### Local development
|
|
29
|
+
|
|
30
|
+
Clone the repo and symlink it into the global plugin directory, where the TUI discovers it:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
git clone https://github.com/frostehhh/opencode-summary
|
|
34
|
+
ln -s "$PWD/opencode-summary" ~/.config/opencode/plugins/opencode-summary
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Edits hot-reload in a running TUI.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Run `/summary` (or "Open summary modal" in the command palette) inside a session.
|
|
42
|
+
|
|
43
|
+
| Key | Action |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `g` | generate a summary |
|
|
46
|
+
| `v` | toggle prompt view |
|
|
47
|
+
| `c` | copy the summary (summary view) |
|
|
48
|
+
| `e` | edit the prompt (prompt view) |
|
|
49
|
+
| `r` | reset the prompt (prompt view) |
|
|
50
|
+
| `j`/`k`, `↑`/`↓` | scroll |
|
|
51
|
+
| `esc` | close |
|
|
52
|
+
|
|
53
|
+
Bind a global key in `cli.json` with `keybinds`: `{"summary.open": "<key>"}`.
|
|
54
|
+
|
|
55
|
+
### Commands
|
|
56
|
+
|
|
57
|
+
Palette group "Summary". Only the first has a slash command.
|
|
58
|
+
|
|
59
|
+
| Command | Id | Does |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Open summary modal | `summary.open` (`/summary`) | opens the modal |
|
|
62
|
+
| Generate summary | `summary.generate` | generates without the modal |
|
|
63
|
+
| Copy summary | `summary.copy` | copies the stored summary |
|
|
64
|
+
| Delete summary | `summary.delete` | removes the stored summary, after confirm |
|
|
65
|
+
| View summary prompt | `summary.prompt.view` | modal in prompt view |
|
|
66
|
+
| Edit summary prompt | `summary.prompt.edit` | `$VISUAL`/`$EDITOR`, else modal editor |
|
|
67
|
+
| Reset summary prompt | `summary.prompt.reset` | deletes the override, after confirm |
|
|
68
|
+
| Toggle summary footer indicator | `summary.indicator.toggle` | hides or shows `summary N turns ago` (the spinner stays) |
|
|
69
|
+
|
|
70
|
+
The indicator setting is persisted, so it survives restarts.
|
|
71
|
+
|
|
72
|
+
## Prompt
|
|
73
|
+
|
|
74
|
+
The default prompt is in [`src/prompt.ts`](src/prompt.ts). Your own prompt is stored at:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
$XDG_CONFIG_HOME/opencode/opencode-summary/summary.md # default: ~/.config/opencode/opencode-summary/summary.md
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This lives outside the plugin, so it survives plugin updates. A fixed line forbidding tool calls is always prepended
|
|
81
|
+
and cannot be edited. `e` opens `$VISUAL`/`$EDITOR` on the file; if neither is set, a multi-line editor opens in the
|
|
82
|
+
modal (`ctrl+s` save, `esc` cancel). `r` deletes the override.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
bun install
|
|
88
|
+
bun test
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`src/lib.ts` is pure and must not import opencode packages; `test/lib.test.ts` covers it. `src/tui.tsx` is checked
|
|
92
|
+
manually with the checklist below.
|
|
93
|
+
|
|
94
|
+
### Manual checklist
|
|
95
|
+
|
|
96
|
+
- [ ] `/summary` in slash completion and palette; on home shows "Open a session first"
|
|
97
|
+
- [ ] `g` shows the summary and "Generated …"; it persists after a TUI restart; `c` copies the markdown
|
|
98
|
+
- [ ] Footer: `summary up to date` after `g`, then `summary N turns ago` as messages are sent
|
|
99
|
+
- [ ] `g` then `esc`: footer spinner, then toast "Summary ready"; `g` mid-run shows "Already generating"
|
|
100
|
+
- [ ] Revert and fork behave as described in the design docs
|
|
101
|
+
- [ ] `e`/`r` only in prompt view, `c` only in summary view
|
|
102
|
+
- [ ] Prompt edit with `$EDITOR` set and unset; saved file appears at the override path above; `r` removes it
|
|
103
|
+
- [ ] Palette "Summary" lists all eight commands; only "Open summary modal" has `/summary`
|
|
104
|
+
- [ ] Toggle footer indicator hides/shows the label and persists after restart
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Commits and PR titles follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by
|
|
109
|
+
[commitlint](https://commitlint.js.org/) (a `commit-msg` hook locally, and CI on pull requests).
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
<type>(<optional scope>): <description>
|
|
113
|
+
|
|
114
|
+
feat: add a new capability -> minor bump
|
|
115
|
+
fix: correct a bug -> patch bump
|
|
116
|
+
feat!: change behavior incompatibly -> breaking (minor while < 1.0)
|
|
117
|
+
docs | refactor | test | chore | ci | build | perf | style
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Releasing
|
|
121
|
+
|
|
122
|
+
Versions follow [SemVer](https://semver.org/) and are derived from commit messages by
|
|
123
|
+
[release-please](https://github.com/googleapis/release-please). While the version is `0.x`, `feat` bumps the minor
|
|
124
|
+
version and `fix` bumps the patch.
|
|
125
|
+
|
|
126
|
+
1. Merge changes to `main` using conventional commits.
|
|
127
|
+
2. release-please opens or updates a "chore(main): release x.y.z" PR with the version bump and `CHANGELOG.md`.
|
|
128
|
+
3. Merge that PR: it creates the `vX.Y.Z` tag and a GitHub Release.
|
|
129
|
+
|
|
130
|
+
Repository setting required once: **Settings → Actions → General → "Allow GitHub Actions to create and approve pull
|
|
131
|
+
requests"**.
|
|
132
|
+
|
|
133
|
+
## Design docs
|
|
134
|
+
|
|
135
|
+
The design, plan and research for this plugin live in the
|
|
136
|
+
[`ai-tools`](https://github.com/frostehhh/ai-tools/tree/main/openspec/changes/create-opencode-summary-plugin) repo.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-summary",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "opencode TUI plugin: generate and view a summary of the current session",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "frostehhh",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/frostehhh/opencode-summary.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/frostehhh/opencode-summary/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/frostehhh/opencode-summary#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"tui",
|
|
20
|
+
"summary"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./tui": "./src/tui.tsx"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"prepare": "husky"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@opencode/plugin": "latest"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@opentui/core": ">=0.6.6",
|
|
39
|
+
"@opentui/solid": ">=0.6.6",
|
|
40
|
+
"solid-js": ">=1.9.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@commitlint/cli": "^21.2.4",
|
|
44
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
45
|
+
"husky": "^9.1.7"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { DEFAULT_PROMPT, GUARD } from "./prompt"
|
|
3
|
+
|
|
4
|
+
export const METADATA_KEY = "opencode-summary"
|
|
5
|
+
export type Metadata = Record<string, unknown>
|
|
6
|
+
export type SummaryRecord = { text: string; generatedAt: number; promptId: string; lastMessageID: string }
|
|
7
|
+
|
|
8
|
+
export const buildRequest = (prompt: string) => `${GUARD}\n\n${prompt}`
|
|
9
|
+
|
|
10
|
+
/** FNV-1a 32-bit, hex. Stable id for "generated with a different prompt". */
|
|
11
|
+
export function promptId(text: string): string {
|
|
12
|
+
let hash = 0x811c9dc5
|
|
13
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193) >>> 0
|
|
14
|
+
return hash.toString(16).padStart(8, "0")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readSummary(metadata: unknown): SummaryRecord | undefined {
|
|
18
|
+
if (!metadata || typeof metadata !== "object") return undefined
|
|
19
|
+
const value = (metadata as Metadata)[METADATA_KEY]
|
|
20
|
+
if (!value || typeof value !== "object") return undefined
|
|
21
|
+
const { text, generatedAt, promptId, lastMessageID } = value as Record<string, unknown>
|
|
22
|
+
if (typeof text !== "string" || typeof generatedAt !== "number") return undefined
|
|
23
|
+
if (typeof promptId !== "string" || typeof lastMessageID !== "string") return undefined
|
|
24
|
+
return { text, generatedAt, promptId, lastMessageID }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const withSummary = (metadata: Metadata | undefined, record: SummaryRecord): Metadata => ({
|
|
28
|
+
...metadata,
|
|
29
|
+
[METADATA_KEY]: record,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export function withoutSummary(metadata: Metadata | undefined): Metadata {
|
|
33
|
+
const { [METADATA_KEY]: _removed, ...rest } = metadata ?? {}
|
|
34
|
+
return rest
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const isBlank = (text: string | undefined) => !text || text.trim() === ""
|
|
38
|
+
|
|
39
|
+
/** Copy of tui/src/component/dialog-stash.tsx getRelativeTime; undefined at 7+ days. */
|
|
40
|
+
export function relativeTime(ts: number, now: number): string | undefined {
|
|
41
|
+
const s = Math.floor((now - ts) / 1000)
|
|
42
|
+
const m = Math.floor(s / 60)
|
|
43
|
+
const h = Math.floor(m / 60)
|
|
44
|
+
const d = Math.floor(h / 24)
|
|
45
|
+
if (s < 60) return "just now"
|
|
46
|
+
if (m < 60) return `${m}m ago`
|
|
47
|
+
if (h < 24) return `${h}h ago`
|
|
48
|
+
if (d < 7) return `${d}d ago`
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Copy of tui/src/util/locale.ts datetime: "<time> · <date>". */
|
|
53
|
+
export function datetime(ts: number): string {
|
|
54
|
+
const date = new Date(ts)
|
|
55
|
+
return `${date.toLocaleTimeString(undefined, { timeStyle: "short" })} · ${date.toLocaleDateString()}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const TURN_LIMIT = 200
|
|
59
|
+
export const GENERATE_TIMEOUT_MS = 180_000
|
|
60
|
+
export type Turns = { count: number; capped: boolean }
|
|
61
|
+
|
|
62
|
+
/** `userMessageIDs` = one page of user messages (newest first, at most `limit`). IDs sort as strings. */
|
|
63
|
+
export function turnsSince(userMessageIDs: readonly string[], lastMessageID: string, limit = TURN_LIMIT): Turns {
|
|
64
|
+
const count = userMessageIDs.filter((id) => id > lastMessageID).length
|
|
65
|
+
return { count, capped: userMessageIDs.length >= limit && count >= limit }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function turnsText({ count, capped }: Turns): string {
|
|
69
|
+
if (capped) return `${TURN_LIMIT}+ turns ago`
|
|
70
|
+
if (count === 0) return "up to date"
|
|
71
|
+
return count === 1 ? "1 turn ago" : `${count} turns ago`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const turnLabel = (turns: Turns) => `summary ${turnsText(turns)}`
|
|
75
|
+
|
|
76
|
+
export function generatedLine(record: SummaryRecord, now: number, turns?: Turns): string {
|
|
77
|
+
const relative = relativeTime(record.generatedAt, now)
|
|
78
|
+
const parts = ["Generated", ...(relative ? [`${relative} ·`] : []), datetime(record.generatedAt)]
|
|
79
|
+
return turns ? `${parts.join(" ")} · ${turnsText(turns)}` : parts.join(" ")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function errorMessage(cause: unknown): string {
|
|
83
|
+
if (cause instanceof Error && cause.message) return cause.message
|
|
84
|
+
return typeof cause === "string" && cause ? cause : "Unknown error"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type BlockReason = "running" | "in-flight"
|
|
88
|
+
|
|
89
|
+
export function blockReason(input: { status: "idle" | "running"; inFlight: boolean }): BlockReason | undefined {
|
|
90
|
+
if (input.inFlight) return "in-flight"
|
|
91
|
+
if (input.status === "running") return "running"
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Newest visible message id. `idsDesc` is newest first; a staged revert hides ids >= `revertBoundary`. */
|
|
96
|
+
export function lastVisibleID(idsDesc: readonly string[], revertBoundary?: string): string | undefined {
|
|
97
|
+
return revertBoundary === undefined ? idsDesc[0] : idsDesc.find((id) => id < revertBoundary)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function blockMessage(reason: BlockReason): string {
|
|
101
|
+
switch (reason) {
|
|
102
|
+
case "in-flight":
|
|
103
|
+
return "Already generating"
|
|
104
|
+
case "running":
|
|
105
|
+
return "Agent is running - wait for it to finish"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isMessageNotFound(error: unknown): boolean {
|
|
110
|
+
if (!error || typeof error !== "object") return false
|
|
111
|
+
const { _tag, name } = error as { _tag?: unknown; name?: unknown }
|
|
112
|
+
return _tag === "MessageNotFoundError" || name === "MessageNotFoundError"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type ValidityCheck = { found: true } | { found: false; notFound: boolean }
|
|
116
|
+
export const validityAction = (check: ValidityCheck): "keep" | "delete" =>
|
|
117
|
+
!check.found && check.notFound ? "delete" : "keep"
|
|
118
|
+
|
|
119
|
+
export type PromptSource = "default" | "override"
|
|
120
|
+
export type ResolvedPrompt = { text: string; source: PromptSource }
|
|
121
|
+
|
|
122
|
+
export const resolvePrompt = (override: string | undefined): ResolvedPrompt =>
|
|
123
|
+
isBlank(override) ? { text: DEFAULT_PROMPT, source: "default" } : { text: override!.trim(), source: "override" }
|
|
124
|
+
|
|
125
|
+
export function saveAction(edited: string): "noop" | "delete" | "write" {
|
|
126
|
+
if (isBlank(edited)) return "noop"
|
|
127
|
+
return edited.trim() === DEFAULT_PROMPT.trim() ? "delete" : "write"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** User-level prompt override location: `<config home>/opencode/opencode-summary/summary.md`. */
|
|
131
|
+
export function promptPaths(env: { XDG_CONFIG_HOME?: string }, home: string): { dir: string; override: string } {
|
|
132
|
+
const base = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config")
|
|
133
|
+
const dir = path.join(base, "opencode", "opencode-summary")
|
|
134
|
+
return { dir, override: path.join(dir, "summary.md") }
|
|
135
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const GUARD = "Do not call any tools and do not take any actions."
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PROMPT = `Summarize the conversation so far for the user.
|
|
4
|
+
Output markdown only, no preamble, about 300 words or less. Use exactly these sections, bullets only below TL;DR; omit a section if empty:
|
|
5
|
+
|
|
6
|
+
## TL;DR
|
|
7
|
+
2-3 sentences.
|
|
8
|
+
## Goal
|
|
9
|
+
## Decisions
|
|
10
|
+
## Progress / current state
|
|
11
|
+
## Open questions / next steps
|
|
12
|
+
## Key files and references`
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { SyntaxStyle, TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
|
|
3
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
4
|
+
import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
5
|
+
import { spawn } from "node:child_process"
|
|
6
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
7
|
+
import { homedir } from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import * as lib from "./lib"
|
|
10
|
+
import { GUARD } from "./prompt"
|
|
11
|
+
|
|
12
|
+
const INSET = 3
|
|
13
|
+
const paths = lib.promptPaths(process.env, homedir())
|
|
14
|
+
type Mode = "summary" | "prompt" | "edit"
|
|
15
|
+
|
|
16
|
+
export default Plugin.define({
|
|
17
|
+
id: "opencode-summary",
|
|
18
|
+
setup(context) {
|
|
19
|
+
const { client, ui } = context
|
|
20
|
+
const theme = () => context.theme.surface("dialog")
|
|
21
|
+
const syntax = SyntaxStyle.fromStyles(
|
|
22
|
+
{
|
|
23
|
+
default: { fg: context.theme.markdown.text },
|
|
24
|
+
"markup.heading": { fg: context.theme.markdown.heading, bold: true },
|
|
25
|
+
"markup.strong": { fg: context.theme.markdown.strong, bold: true },
|
|
26
|
+
"markup.italic": { fg: context.theme.markdown.emphasis, italic: true },
|
|
27
|
+
"markup.list": { fg: context.theme.markdown.listItem },
|
|
28
|
+
"markup.quote": { fg: context.theme.markdown.blockQuote, italic: true },
|
|
29
|
+
"markup.raw": { fg: context.theme.markdown.code },
|
|
30
|
+
"markup.link": { fg: context.theme.markdown.link, underline: true },
|
|
31
|
+
},
|
|
32
|
+
context.renderer.nativeScene,
|
|
33
|
+
)
|
|
34
|
+
const toastError = (cause: unknown) => ui.toast.show({ variant: "error", message: lib.errorMessage(cause) })
|
|
35
|
+
|
|
36
|
+
async function readMetadata(sessionID: string): Promise<lib.Metadata> {
|
|
37
|
+
return ((await client.session.get({ sessionID })).metadata ?? {}) as lib.Metadata
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read-merge-write; `undefined` removes the summary. */
|
|
41
|
+
async function writeSummary(sessionID: string, record: lib.SummaryRecord | undefined): Promise<void> {
|
|
42
|
+
const current = await readMetadata(sessionID)
|
|
43
|
+
const metadata = record ? lib.withSummary(current, record) : lib.withoutSummary(current)
|
|
44
|
+
await client.session.update({ sessionID, metadata: metadata as never })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadSummary(sessionID: string): Promise<lib.SummaryRecord | undefined> {
|
|
48
|
+
const record = lib.readSummary(await readMetadata(sessionID))
|
|
49
|
+
if (!record) return undefined
|
|
50
|
+
const check: lib.ValidityCheck = await client.session.message
|
|
51
|
+
.get({ sessionID, messageID: record.lastMessageID })
|
|
52
|
+
.then(
|
|
53
|
+
() => ({ found: true }) as const,
|
|
54
|
+
(error: unknown) => ({ found: false, notFound: lib.isMessageNotFound(error) }) as const,
|
|
55
|
+
)
|
|
56
|
+
if (lib.validityAction(check) === "keep") return record
|
|
57
|
+
await writeSummary(sessionID, undefined)
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function blocked(sessionID: string): Promise<lib.BlockReason | undefined> {
|
|
62
|
+
return lib.blockReason({
|
|
63
|
+
status: context.data.session.status(sessionID),
|
|
64
|
+
inFlight: inFlight(sessionID),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [tick, setTick] = createSignal(0)
|
|
69
|
+
let pending: ReturnType<typeof setTimeout> | undefined
|
|
70
|
+
const bump = () => {
|
|
71
|
+
clearTimeout(pending)
|
|
72
|
+
pending = setTimeout(() => setTick((n) => n + 1), 150)
|
|
73
|
+
}
|
|
74
|
+
const stops = (
|
|
75
|
+
[
|
|
76
|
+
"session.inbox.delivered",
|
|
77
|
+
"session.metadata.updated",
|
|
78
|
+
"session.revert.committed",
|
|
79
|
+
"session.revert.staged",
|
|
80
|
+
"session.revert.cleared",
|
|
81
|
+
"session.execution.started",
|
|
82
|
+
"session.execution.succeeded",
|
|
83
|
+
"session.execution.failed",
|
|
84
|
+
"session.execution.interrupted",
|
|
85
|
+
] as const
|
|
86
|
+
).map((type) => context.data.on(type, bump))
|
|
87
|
+
|
|
88
|
+
const [flight, setFlight] = context.storage.memory("inflight", { initial: { sessions: [] as string[] } })
|
|
89
|
+
const inFlight = (sessionID: string) => flight.sessions.includes(sessionID)
|
|
90
|
+
const [modalFor, setModalFor] = createSignal<string>()
|
|
91
|
+
|
|
92
|
+
async function countTurns(sessionID: string, lastMessageID: string): Promise<lib.Turns> {
|
|
93
|
+
const page = await client.message.list({ sessionID, type: "user", order: "desc", limit: lib.TURN_LIMIT })
|
|
94
|
+
return lib.turnsSince(page.data.map((message) => message.id), lastMessageID)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadView(sessionID: string) {
|
|
98
|
+
const record = await loadSummary(sessionID)
|
|
99
|
+
return record ? { record, turns: await countTurns(sessionID, record.lastMessageID) } : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readOverride(): Promise<string | undefined> {
|
|
103
|
+
return readFile(paths.override, "utf8").catch((error: NodeJS.ErrnoException) =>
|
|
104
|
+
error.code === "ENOENT" ? undefined : Promise.reject(error),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
const loadPrompt = async () => lib.resolvePrompt(await readOverride())
|
|
108
|
+
|
|
109
|
+
async function applyEdited(edited: string, previous: string | undefined): Promise<void> {
|
|
110
|
+
switch (lib.saveAction(edited)) {
|
|
111
|
+
case "noop":
|
|
112
|
+
if (previous === undefined) await rm(paths.override, { force: true })
|
|
113
|
+
else await writeFile(paths.override, previous)
|
|
114
|
+
return void ui.toast.show({ variant: "warning", message: "Empty prompt - not saved" })
|
|
115
|
+
case "delete":
|
|
116
|
+
await rm(paths.override, { force: true })
|
|
117
|
+
return void ui.toast.show({ message: "Prompt matches default - override removed" })
|
|
118
|
+
case "write":
|
|
119
|
+
await mkdir(path.dirname(paths.override), { recursive: true })
|
|
120
|
+
await writeFile(paths.override, edited.trim() + "\n")
|
|
121
|
+
return void ui.toast.show({ variant: "success", message: "Prompt saved" })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function spawnEditor(command: string, file: string): Promise<void> {
|
|
126
|
+
const [bin, ...args] = command.split(" ")
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const child = spawn(bin!, [...args, file], { stdio: "inherit" })
|
|
129
|
+
child.on("error", reject)
|
|
130
|
+
child.on("exit", (code, signal) =>
|
|
131
|
+
code === 0 ? resolve() : reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`)),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const externalEditor = () => process.env.VISUAL || process.env.EDITOR
|
|
137
|
+
|
|
138
|
+
async function editWithEditor(sessionID: string, editor: string): Promise<void> {
|
|
139
|
+
const previous = await readOverride()
|
|
140
|
+
await mkdir(path.dirname(paths.override), { recursive: true })
|
|
141
|
+
if (previous === undefined) await writeFile(paths.override, (await loadPrompt()).text + "\n")
|
|
142
|
+
await context.renderer.suspend()
|
|
143
|
+
try {
|
|
144
|
+
await spawnEditor(editor, paths.override)
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
toastError(cause)
|
|
147
|
+
return
|
|
148
|
+
} finally {
|
|
149
|
+
await context.renderer.resume()
|
|
150
|
+
}
|
|
151
|
+
await applyEdited((await readOverride()) ?? "", previous)
|
|
152
|
+
openModal(sessionID, "prompt")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resetPrompt(sessionID: string, reopen = true): Promise<void> {
|
|
156
|
+
const confirmed = await ui.dialog.confirm({
|
|
157
|
+
title: "Reset summary prompt?",
|
|
158
|
+
message: `Delete ${ui.format.path(paths.override)} and use the default prompt.`,
|
|
159
|
+
label: { confirm: "Reset", cancel: "Cancel" },
|
|
160
|
+
})
|
|
161
|
+
if (confirmed) {
|
|
162
|
+
await rm(paths.override, { force: true })
|
|
163
|
+
ui.toast.show({ message: "Prompt reset to default" })
|
|
164
|
+
}
|
|
165
|
+
if (reopen) openModal(sessionID, "prompt")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Returns true when a new record was written. */
|
|
169
|
+
async function generate(sessionID: string): Promise<boolean> {
|
|
170
|
+
const prompt = (await loadPrompt()).text
|
|
171
|
+
const [info, page] = await Promise.all([
|
|
172
|
+
client.session.get({ sessionID }),
|
|
173
|
+
client.message.list({ sessionID, order: "desc", limit: lib.TURN_LIMIT }),
|
|
174
|
+
])
|
|
175
|
+
// A staged revert hides messages from revert.messageID on; cap lastMessageID at the boundary.
|
|
176
|
+
const lastMessageID = lib.lastVisibleID(page.data.map((message) => message.id), info.revert?.messageID)
|
|
177
|
+
if (!lastMessageID) {
|
|
178
|
+
ui.toast.show({ variant: "warning", message: "Nothing to summarize yet" })
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
// No abort signal on session.generate: race a timeout so a hung call cannot keep the session "in flight" forever.
|
|
182
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
183
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
184
|
+
timer = setTimeout(() => reject(new Error("Summary timed out")), lib.GENERATE_TIMEOUT_MS)
|
|
185
|
+
})
|
|
186
|
+
const result = await Promise.race([client.session.generate({ sessionID, prompt: lib.buildRequest(prompt) }), timeout])
|
|
187
|
+
.catch((cause: unknown) => (toastError(cause), undefined))
|
|
188
|
+
.finally(() => clearTimeout(timer))
|
|
189
|
+
if (!result) return false
|
|
190
|
+
if (lib.isBlank(result.text)) {
|
|
191
|
+
ui.toast.show({ variant: "error", message: "Summary came back empty" })
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
await writeSummary(sessionID, {
|
|
195
|
+
text: result.text.trim(),
|
|
196
|
+
generatedAt: Date.now(),
|
|
197
|
+
promptId: lib.promptId(prompt),
|
|
198
|
+
lastMessageID,
|
|
199
|
+
})
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const [settings, setSettings] = context.storage.store("settings", { initial: { indicator: true } })
|
|
204
|
+
|
|
205
|
+
function copyText(text: string) {
|
|
206
|
+
if (!context.renderer.copyToClipboardOSC52(text))
|
|
207
|
+
ui.toast.show({ variant: "warning", message: "Copy not supported by this terminal" })
|
|
208
|
+
else ui.toast.show({ variant: "success", message: "Summary copied" })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function currentSession(): string | undefined {
|
|
212
|
+
const route = ui.router.current()
|
|
213
|
+
if (route.type === "session") return route.sessionID
|
|
214
|
+
ui.toast.show({ variant: "warning", message: "Open a session first" })
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function deleteSummary(sessionID: string): Promise<void> {
|
|
219
|
+
if (!(await loadSummary(sessionID))) return void ui.toast.show({ message: "No summary to delete" })
|
|
220
|
+
const confirmed = await ui.dialog.confirm({
|
|
221
|
+
title: "Delete summary?",
|
|
222
|
+
message: "Remove this session's stored summary.",
|
|
223
|
+
label: { confirm: "Delete", cancel: "Cancel" },
|
|
224
|
+
})
|
|
225
|
+
if (!confirmed) return
|
|
226
|
+
await writeSummary(sessionID, undefined)
|
|
227
|
+
ui.toast.show({ message: "Summary deleted" })
|
|
228
|
+
bump()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function startGenerate(sessionID: string): Promise<void> {
|
|
232
|
+
const reason = await blocked(sessionID)
|
|
233
|
+
if (reason) return void ui.toast.show({ variant: "warning", message: lib.blockMessage(reason) })
|
|
234
|
+
setFlight((draft) => void draft.sessions.push(sessionID))
|
|
235
|
+
try {
|
|
236
|
+
const written = await generate(sessionID)
|
|
237
|
+
if (written && modalFor() !== sessionID) ui.toast.show({ variant: "success", message: "Summary ready", sessionID })
|
|
238
|
+
} catch (cause) {
|
|
239
|
+
toastError(cause)
|
|
240
|
+
} finally {
|
|
241
|
+
setFlight((draft) => void (draft.sessions = draft.sessions.filter((id) => id !== sessionID)))
|
|
242
|
+
bump()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function Spinner(props: { label: string }) {
|
|
247
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
248
|
+
const [i, setI] = createSignal(0)
|
|
249
|
+
const timer = setInterval(() => setI((n) => (n + 1) % frames.length), 80)
|
|
250
|
+
onCleanup(() => clearInterval(timer))
|
|
251
|
+
return <text fg={context.theme.hue.interactive[200]}>{`${frames[i()]} ${props.label}`}</text>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function FooterStatus(props: { sessionID?: string }) {
|
|
255
|
+
const [view] = createResource(
|
|
256
|
+
() => (props.sessionID ? ([props.sessionID, tick()] as const) : undefined),
|
|
257
|
+
([sessionID]) => loadView(sessionID).catch(() => undefined),
|
|
258
|
+
)
|
|
259
|
+
return (
|
|
260
|
+
<Show when={props.sessionID}>
|
|
261
|
+
{(sessionID) => (
|
|
262
|
+
<Show when={!inFlight(sessionID())} fallback={<Spinner label="/summary" />}>
|
|
263
|
+
<Show when={settings.indicator && view()}>
|
|
264
|
+
{(current) => <text fg={context.theme.text.muted}>{lib.turnLabel(current().turns)}</text>}
|
|
265
|
+
</Show>
|
|
266
|
+
</Show>
|
|
267
|
+
)}
|
|
268
|
+
</Show>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
ui.slot({ append: "prompt.footer.status", render: (props) => <FooterStatus sessionID={props.sessionID} /> })
|
|
273
|
+
|
|
274
|
+
function Hints(props: { items: [title: string, key: string][] }) {
|
|
275
|
+
return (
|
|
276
|
+
<box flexDirection="row" gap={2}>
|
|
277
|
+
<For each={props.items}>{([title, key]) => <text fg={theme().text.muted}>{`${title} ${key}`}</text>}</For>
|
|
278
|
+
</box>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function PromptEditor(props: { initial: string; height: number; onDone: (saved: boolean) => void }) {
|
|
283
|
+
const [target, setTarget] = createSignal<TextareaRenderable>()
|
|
284
|
+
let textarea: TextareaRenderable | undefined
|
|
285
|
+
const save = async () => {
|
|
286
|
+
const previous = await readOverride()
|
|
287
|
+
await applyEdited(textarea?.plainText ?? "", previous).then(() => props.onDone(true), toastError)
|
|
288
|
+
}
|
|
289
|
+
context.keymap.layer(() => ({
|
|
290
|
+
mode: "modal",
|
|
291
|
+
priority: 1,
|
|
292
|
+
target,
|
|
293
|
+
enabled: target() !== undefined,
|
|
294
|
+
commands: [
|
|
295
|
+
{ bind: "ctrl+s", title: "Save prompt", group: "Dialog", run: () => void save() },
|
|
296
|
+
{ bind: "escape", title: "Cancel edit", group: "Dialog", run: () => props.onDone(false) },
|
|
297
|
+
],
|
|
298
|
+
}))
|
|
299
|
+
onMount(() => setTimeout(() => textarea && !textarea.isDestroyed && textarea.focus(), 1))
|
|
300
|
+
return (
|
|
301
|
+
<box gap={1}>
|
|
302
|
+
<text fg={theme().text.muted}>{`fixed: ${GUARD}`}</text>
|
|
303
|
+
<textarea
|
|
304
|
+
ref={(element: TextareaRenderable) => ((textarea = element), setTarget(element))}
|
|
305
|
+
initialValue={props.initial}
|
|
306
|
+
height={props.height}
|
|
307
|
+
wrapMode="word"
|
|
308
|
+
textColor={theme().text.formfield.base}
|
|
309
|
+
focusedTextColor={theme().text.formfield.base}
|
|
310
|
+
cursorColor={theme().text.base}
|
|
311
|
+
/>
|
|
312
|
+
<Hints items={[["save", "ctrl+s"], ["cancel", "esc"]]} />
|
|
313
|
+
</box>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function SummaryDialog(props: { sessionID: string; mode: Mode }) {
|
|
318
|
+
const dimensions = useTerminalDimensions()
|
|
319
|
+
const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height * 0.6)))
|
|
320
|
+
const [mode, setMode] = createSignal<Mode>(props.mode)
|
|
321
|
+
const [view] = createResource(() => [props.sessionID, tick()] as const, ([id]) => loadView(id).catch(toastError))
|
|
322
|
+
const [reason] = createResource(() => [props.sessionID, tick()] as const, ([id]) => blocked(id).catch(() => undefined))
|
|
323
|
+
const [prompt, { refetch: refetchPrompt }] = createResource(() => [mode(), tick()] as const, () => loadPrompt())
|
|
324
|
+
const record = () => view()?.record
|
|
325
|
+
const busy = () => inFlight(props.sessionID)
|
|
326
|
+
const stale = () => view() && prompt() && view()!.record.promptId !== lib.promptId(prompt()!.text)
|
|
327
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
328
|
+
|
|
329
|
+
const run = () => void startGenerate(props.sessionID)
|
|
330
|
+
const copy = () => {
|
|
331
|
+
const text = record()?.text
|
|
332
|
+
if (text) copyText(text)
|
|
333
|
+
}
|
|
334
|
+
const edit = () => {
|
|
335
|
+
const editor = externalEditor()
|
|
336
|
+
if (editor) void editWithEditor(props.sessionID, editor).catch(toastError)
|
|
337
|
+
else setMode("edit")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
context.keymap.layer(() => ({
|
|
341
|
+
mode: "modal",
|
|
342
|
+
enabled: mode() !== "edit",
|
|
343
|
+
commands: [
|
|
344
|
+
{ bind: "g", title: "Generate summary", group: "Dialog", run },
|
|
345
|
+
{ bind: "v", title: "Toggle prompt view", group: "Dialog", run: () => setMode((m) => (m === "summary" ? "prompt" : "summary")) },
|
|
346
|
+
],
|
|
347
|
+
}))
|
|
348
|
+
context.keymap.layer(() => ({
|
|
349
|
+
mode: "modal",
|
|
350
|
+
enabled: mode() === "summary",
|
|
351
|
+
commands: [{ bind: "c", title: "Copy summary", group: "Dialog", run: copy }],
|
|
352
|
+
}))
|
|
353
|
+
context.keymap.layer(() => ({
|
|
354
|
+
mode: "modal",
|
|
355
|
+
enabled: mode() === "prompt",
|
|
356
|
+
commands: [
|
|
357
|
+
{ bind: "e", title: "Edit prompt", group: "Dialog", run: edit },
|
|
358
|
+
{ bind: "r", title: "Reset prompt", group: "Dialog", run: () => void resetPrompt(props.sessionID).catch(toastError) },
|
|
359
|
+
],
|
|
360
|
+
}))
|
|
361
|
+
|
|
362
|
+
useKeyboard((event) => {
|
|
363
|
+
if (!scroll || mode() === "edit") return
|
|
364
|
+
if (event.name === "up" || event.name === "k") return scroll.scrollBy(-1)
|
|
365
|
+
if (event.name === "down" || event.name === "j") return scroll.scrollBy(1)
|
|
366
|
+
if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
|
|
367
|
+
if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
|
|
368
|
+
if (event.name === "home") return scroll.scrollTo(0)
|
|
369
|
+
if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const summaryBody = () => (
|
|
373
|
+
<Show when={record()}>
|
|
374
|
+
{(current) => (
|
|
375
|
+
<scrollbox
|
|
376
|
+
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
|
377
|
+
maxHeight={maxHeight()}
|
|
378
|
+
scrollbarOptions={{ visible: false }}
|
|
379
|
+
>
|
|
380
|
+
<markdown content={current().text} syntaxStyle={syntax} conceal fg={theme().markdown.text} />
|
|
381
|
+
</scrollbox>
|
|
382
|
+
)}
|
|
383
|
+
</Show>
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<box paddingLeft={INSET} paddingRight={INSET} paddingBottom={1} gap={1}>
|
|
388
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
389
|
+
<text attributes={TextAttributes.BOLD} fg={theme().text.base}>/summary</text>
|
|
390
|
+
<text fg={theme().text.muted}>esc</text>
|
|
391
|
+
</box>
|
|
392
|
+
<text fg={theme().text.muted}>
|
|
393
|
+
{busy() ? "Generating…" : view() ? lib.generatedLine(view()!.record, Date.now(), view()!.turns) : "No summary yet - press g to generate"}
|
|
394
|
+
</text>
|
|
395
|
+
<Show when={mode() === "edit" && prompt()}>
|
|
396
|
+
<PromptEditor
|
|
397
|
+
initial={prompt()!.text}
|
|
398
|
+
height={Math.max(6, maxHeight() - 4)}
|
|
399
|
+
onDone={(saved) => {
|
|
400
|
+
setMode("prompt")
|
|
401
|
+
if (saved) void refetchPrompt()
|
|
402
|
+
}}
|
|
403
|
+
/>
|
|
404
|
+
</Show>
|
|
405
|
+
<Show when={mode() === "prompt"}>
|
|
406
|
+
<scrollbox ref={(element: ScrollBoxRenderable) => (scroll = element)} maxHeight={maxHeight()} scrollbarOptions={{ visible: false }}>
|
|
407
|
+
<box gap={1}>
|
|
408
|
+
<text fg={theme().text.muted}>{`fixed: ${GUARD}`}</text>
|
|
409
|
+
<text fg={theme().text.base}>{prompt()?.text ?? ""}</text>
|
|
410
|
+
<text fg={theme().text.muted}>
|
|
411
|
+
{prompt()?.source === "override" ? `source: override (${ui.format.path(paths.override)})` : "source: default (prompt.ts)"}
|
|
412
|
+
</text>
|
|
413
|
+
</box>
|
|
414
|
+
</scrollbox>
|
|
415
|
+
</Show>
|
|
416
|
+
<Show when={mode() === "summary"}>{summaryBody()}</Show>
|
|
417
|
+
<Show when={mode() === "summary" && stale()}>
|
|
418
|
+
<text fg={theme().text.muted}>Generated with a different prompt</text>
|
|
419
|
+
</Show>
|
|
420
|
+
<Show when={mode() !== "edit"}>
|
|
421
|
+
<Hints
|
|
422
|
+
items={[
|
|
423
|
+
[reason() ? `generate (${reason()})` : "generate", "g"],
|
|
424
|
+
[mode() === "summary" ? "prompt" : "summary", "v"],
|
|
425
|
+
...(mode() === "prompt" ? ([["edit", "e"], ["reset", "r"]] as [string, string][]) : []),
|
|
426
|
+
...(mode() === "summary" ? ([["copy", "c"]] as [string, string][]) : []),
|
|
427
|
+
["scroll", "j/k"],
|
|
428
|
+
]}
|
|
429
|
+
/>
|
|
430
|
+
</Show>
|
|
431
|
+
</box>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function openModal(sessionID: string, mode: Mode = "summary") {
|
|
436
|
+
setModalFor(sessionID)
|
|
437
|
+
ui.dialog.show(() => <SummaryDialog sessionID={sessionID} mode={mode} />, () => setModalFor(undefined))
|
|
438
|
+
ui.dialog.set({ size: "large", centered: true })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
ui.slot({
|
|
442
|
+
append: "app",
|
|
443
|
+
render() {
|
|
444
|
+
context.keymap.layer(() => ({
|
|
445
|
+
mode: "global",
|
|
446
|
+
commands: [
|
|
447
|
+
{
|
|
448
|
+
id: "summary.open",
|
|
449
|
+
title: "Open summary modal",
|
|
450
|
+
description: "View or generate a summary of this session",
|
|
451
|
+
group: "Summary",
|
|
452
|
+
palette: true,
|
|
453
|
+
slash: { name: "summary" },
|
|
454
|
+
run() {
|
|
455
|
+
const sessionID = currentSession()
|
|
456
|
+
if (sessionID) openModal(sessionID)
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
id: "summary.generate",
|
|
461
|
+
title: "Generate summary",
|
|
462
|
+
description: "Generate a summary without opening the modal",
|
|
463
|
+
group: "Summary",
|
|
464
|
+
palette: true,
|
|
465
|
+
run() {
|
|
466
|
+
const sessionID = currentSession()
|
|
467
|
+
if (sessionID) void startGenerate(sessionID)
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "summary.copy",
|
|
472
|
+
title: "Copy summary",
|
|
473
|
+
description: "Copy the stored summary to the clipboard",
|
|
474
|
+
group: "Summary",
|
|
475
|
+
palette: true,
|
|
476
|
+
run() {
|
|
477
|
+
const sessionID = currentSession()
|
|
478
|
+
if (!sessionID) return
|
|
479
|
+
void loadSummary(sessionID).then(
|
|
480
|
+
(record) => (record ? copyText(record.text) : ui.toast.show({ message: "No summary yet" })),
|
|
481
|
+
toastError,
|
|
482
|
+
)
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: "summary.delete",
|
|
487
|
+
title: "Delete summary",
|
|
488
|
+
description: "Remove the stored summary of this session",
|
|
489
|
+
group: "Summary",
|
|
490
|
+
palette: true,
|
|
491
|
+
run() {
|
|
492
|
+
const sessionID = currentSession()
|
|
493
|
+
if (sessionID) void deleteSummary(sessionID).catch(toastError)
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: "summary.prompt.view",
|
|
498
|
+
title: "View summary prompt",
|
|
499
|
+
description: "Show the prompt used for summaries",
|
|
500
|
+
group: "Summary",
|
|
501
|
+
palette: true,
|
|
502
|
+
run() {
|
|
503
|
+
const sessionID = currentSession()
|
|
504
|
+
if (sessionID) openModal(sessionID, "prompt")
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
id: "summary.prompt.edit",
|
|
509
|
+
title: "Edit summary prompt",
|
|
510
|
+
description: "Edit the prompt used for summaries",
|
|
511
|
+
group: "Summary",
|
|
512
|
+
palette: true,
|
|
513
|
+
run() {
|
|
514
|
+
const sessionID = currentSession()
|
|
515
|
+
if (!sessionID) return
|
|
516
|
+
const editor = externalEditor()
|
|
517
|
+
if (editor) void editWithEditor(sessionID, editor).catch(toastError)
|
|
518
|
+
else openModal(sessionID, "edit")
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
id: "summary.prompt.reset",
|
|
523
|
+
title: "Reset summary prompt",
|
|
524
|
+
description: "Restore the default summary prompt",
|
|
525
|
+
group: "Summary",
|
|
526
|
+
palette: true,
|
|
527
|
+
run() {
|
|
528
|
+
const sessionID = currentSession()
|
|
529
|
+
if (sessionID) void resetPrompt(sessionID, false).catch(toastError)
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
id: "summary.indicator.toggle",
|
|
534
|
+
title: "Toggle summary footer indicator",
|
|
535
|
+
description: "Show or hide the turns-ago indicator in the prompt footer",
|
|
536
|
+
group: "Summary",
|
|
537
|
+
palette: true,
|
|
538
|
+
run() {
|
|
539
|
+
const next = !settings.indicator
|
|
540
|
+
void setSettings((draft) => void (draft.indicator = next)).then(
|
|
541
|
+
() => ui.toast.show({ message: next ? "Footer indicator shown" : "Footer indicator hidden" }),
|
|
542
|
+
toastError,
|
|
543
|
+
)
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
}))
|
|
548
|
+
return null
|
|
549
|
+
},
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
return () => {
|
|
553
|
+
clearTimeout(pending)
|
|
554
|
+
stops.forEach((stop) => stop())
|
|
555
|
+
void context.renderer.idle().then(() => syntax.destroy())
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
})
|