UIに表のコピーや会話の折りたたみボタン追加するMod

metalmental (ユーザ名) の claude code の plugins 等をまとめたリポジトリ
| plugin名 | 説明 |
|---|---|
| ask-user-question-enforce | Skillのユーザー確認をAskUserQuestionに統一させるSKILL |
| aws-best-practice | AWSリソースの推奨設定SKILL |
| career-transition-support | ITエンジニア転職SKILL |
| code-review-by-book | 読んだ技術書をもとにしたコードレビューWorkflow |
| git-github | commit、worktree/branch、pr作成Skill |
| local-doc-search | ローカルPCのファイルを全文検索やRAG検索するMCP |
| sandbox-enforce | 全claude設定ファイルでsandbox.enabledにするSkill |
| session-daily-report | 全プロジェクトのセッション履歴から日報を作成するWorkflow |
| statusline | ModelやBranch名、Python等のversionを表示するstatusline Mod |
| tool-failure-log | Toolの実行エラーを調査可能にするSkill |
| ui-plus | UIに表のコピーや会話の折りたたみボタン追加するMod |
| verified-web-search | 検索したURLの完全なページを取得し、正確な1件のみ返すWorkflow |
# Launch Claude
claude
# Add this marketplace
/plugin marketplace add https://github.com/Flupinochan/metalmental-claude
# Reload plugins
/reload-plugins
/plugin install git-github@metalmental-plugins-official
/git-github:commit-enforce
claude --plugin-dir "$(pwd)/plugins/session-daily-report"
plugin.json の version を上げることplugin-dev:plugin-validator や claude plugin validate, test, eval 実行して動作確認するかユーザに確認すること/help skillを作成することhooks/register.tsx 355 lines1import type { Register } from "claude-code";
2
3import {
4 copyText,
5 getCopyButtonStyle,
6 padLabelToWidth,
7 readCopyResults,
8} from "./copyButton";
9import { COLOR } from "./colors";
10import {
11 headingOf,
12 isFirstBlock,
13 isGroupFoldable,
14 isOpen,
15 noteAnswerBlock,
16 noteQuestionSeen,
17 startAnswerTurn,
18 toggleOpen,
19} from "./foldState";
20import { firstLineOf, isMultiline } from "./foldText";
21import {
22 ANSWER_TOGGLE_RESERVED,
23 QUESTION_TOGGLE_RESERVED,
24 padLabelToRight,
25} from "./labelWidth";
26import { splitCopyTargetsFromAssistantMessage } from "./splitCopyTargetsFromAssistantMessage";
27import {
28 endTurn,
29 isAgentRowShown,
30 isInfoNoticeShown,
31 isToolGroupShown,
32 isToolShown,
33 readTurnState,
34 recordToolCall,
35 startTurn,
36} from "./turnVisibility";
37
38/**
39 * ui-plus の描画を登録する
40 * @param on フックを登録する関数
41 */
42export const register: Register = (on) => {
43 on("ui.render", { component: "AssistantMessage" }, async ($, event, next) => {
44 if (event.props.isSummary === true) {
45 return next(event);
46 }
47
48 const group = noteAnswerBlock($, event.requestId, event.props.text);
49
50 const { Box, Button, Markdown, Text } = $.ui.resolve(event);
51 const bullet = event.props.isFirstOfReply ? "● " : " ";
52
53 if (!isGroupFoldable(group)) {
54 const plainText = event.props.text.trim();
55
56 if (plainText === "") {
57 return next(event);
58 }
59
60 return (
61 <Box flexDirection="row">
62 <Text>{bullet}</Text>
63 <Box flexGrow={1}>
64 <Text color={COLOR.green}>{plainText}</Text>
65 </Box>
66 </Box>
67 );
68 }
69
70 const open = await isOpen($, group.key);
71 const isFirst = isFirstBlock(group, event.requestId);
72
73 if (!open) {
74 if (!isFirst) {
75 return <Box />;
76 }
77
78 return (
79 <Box flexDirection="row">
80 <Text>{bullet}</Text>
81 <Button
82 key="toggle"
83 plain
84 onPress={() => void toggleOpen($, group.key, open)}
85 >
86 <Text color={COLOR.green}>
87 {padLabelToRight(
88 `▶ ${headingOf(group)}`,
89 event.viewport?.columns,
90 ANSWER_TOGGLE_RESERVED,
91 )}
92 </Text>
93 </Button>
94 </Box>
95 );
96 }
97
98 const messageParts = splitCopyTargetsFromAssistantMessage(event.props.text);
99 const results = await readCopyResults($);
100 const body = (
101 <Box flexDirection="column" flexGrow={1}>
102 {messageParts.map((part, index) => {
103 if (part.kind === "plain") {
104 return <Markdown key={`text-${index}`} text={part.plainText} />;
105 }
106
107 const id = `${event.requestId}:${index}`;
108 const { label, color, hoverColor } = getCopyButtonStyle(
109 part.kind,
110 results[id],
111 );
112 const hover = { scope: `copy-${part.kind}:${id}` };
113 const textToCopy =
114 part.kind === "table" ? part.tableText : part.codeText;
115 const displayedText =
116 part.kind === "table" ? part.tableText : part.codeBlockText;
117
118 return (
119 <Box key={`${part.kind}-${index}`} flexDirection="column">
120 <Box
121 borderStyle="round"
122 borderColor={color}
123 hover={{ ...hover, borderColor: hoverColor }}
124 >
125 <Button
126 key={`copy-${part.kind}-${index}`}
127 plain
128 onPress={(press) =>
129 void copyText($, id, textToCopy, press.surface)
130 }
131 >
132 <Text color={color} hover={{ ...hover, color: hoverColor }}>
133 {padLabelToWidth(label, event.viewport?.columns)}
134 </Text>
135 </Button>
136 </Box>
137 {part.kind === "code" ? (
138 <Box
139 key={`code-frame-${index}`}
140 borderStyle="round"
141 borderColor={color}
142 >
143 <Markdown key={`code-text-${index}`} text={displayedText} />
144 </Box>
145 ) : (
146 <Markdown key={`table-text-${index}`} text={displayedText} />
147 )}
148 </Box>
149 );
150 })}
151 </Box>
152 );
153
154 if (!isFirst) {
155 return (
156 <Box flexDirection="row">
157 <Text>{bullet}</Text>
158 {body}
159 </Box>
160 );
161 }
162
163 return (
164 <Box flexDirection="column">
165 <Box flexDirection="row">
166 <Text>{bullet}</Text>
167 <Button
168 key="toggle"
169 plain
170 onPress={() => void toggleOpen($, group.key, open)}
171 >
172 <Text color={COLOR.orange}>
173 {padLabelToRight(
174 "▼ 閉じる",
175 event.viewport?.columns,
176 ANSWER_TOGGLE_RESERVED,
177 )}
178 </Text>
179 </Button>
180 </Box>
181 <Box flexDirection="row">
182 <Text>{" "}</Text>
183 {body}
184 </Box>
185 </Box>
186 );
187 });
188
189 on("ui.render", { component: "UserMessage" }, async ($, event, next) => {
190 if (event.props.from !== undefined || event.props.task !== undefined) {
191 const turn = await readTurnState($);
192 const isShown = isAgentRowShown(turn, event.requestId);
193
194 if (event.props.isExpanded || isShown) {
195 return next(event);
196 }
197
198 const { Box } = $.ui.resolve(event);
199
200 return <Box />;
201 }
202
203 if (event.props.origin.kind !== "composer" || event.props.isExpanded) {
204 return next(event);
205 }
206
207 const { Box, Button, Text } = $.ui.resolve(event);
208
209 if (!isMultiline(event.props.text)) {
210 return <Text color={COLOR.blue}>{event.props.text}</Text>;
211 }
212
213 noteQuestionSeen($, event.requestId);
214 const open = await isOpen($, event.requestId);
215
216 if (open) {
217 const standard = await next(event);
218
219 return (
220 <Box flexDirection="column">
221 <Button
222 key="toggle"
223 plain
224 onPress={() => void toggleOpen($, event.requestId, open)}
225 >
226 <Text color={COLOR.orange}>
227 {padLabelToRight(
228 "▼ 閉じる",
229 event.viewport?.columns,
230 QUESTION_TOGGLE_RESERVED,
231 )}
232 </Text>
233 </Button>
234 {standard}
235 </Box>
236 );
237 }
238
239 return (
240 <Button
241 key="toggle"
242 plain
243 onPress={() => void toggleOpen($, event.requestId, open)}
244 >
245 <Text color={COLOR.blue}>
246 {padLabelToRight(
247 `▶ ${firstLineOf(event.props.text)}`,
248 event.viewport?.columns,
249 QUESTION_TOGGLE_RESERVED,
250 )}
251 </Text>
252 </Button>
253 );
254 });
255
256 on("turn.start", async ($, event, next) => {
257 if (event.agentId === undefined) {
258 startAnswerTurn($, event.turnId);
259 await startTurn($);
260 }
261
262 return next(event);
263 });
264
265 on("turn.complete", async ($, event, next) => {
266 if (event.agentId === undefined) {
267 await endTurn($);
268 }
269
270 return next(event);
271 });
272
273 on("tool.call", async ($, event, next) => {
274 await recordToolCall($, event.tool_use_id);
275
276 return next(event);
277 });
278
279 on("ui.render", { component: "ToolUse" }, async ($, event, next) => {
280 const turn = await readTurnState($);
281
282 if (
283 event.props.isExpanded === true ||
284 isToolShown(turn, event.props.tool_use_id)
285 ) {
286 return next(event);
287 }
288
289 const { Box } = $.ui.resolve(event);
290
291 return <Box />;
292 });
293
294 on("ui.render", { component: "ToolResult" }, async ($, event, next) => {
295 const turn = await readTurnState($);
296
297 if (
298 event.props.isExpanded === true ||
299 isToolShown(turn, event.props.tool_use_id)
300 ) {
301 return next(event);
302 }
303
304 const { Box } = $.ui.resolve(event);
305
306 return <Box />;
307 });
308
309 on("ui.render", { component: "ToolGroup" }, async ($, event, next) => {
310 const turn = await readTurnState($);
311
312 if (event.props.isExpanded || isToolGroupShown(turn, event.props.calls)) {
313 return next(event);
314 }
315
316 const { Box } = $.ui.resolve(event);
317
318 return <Box />;
319 });
320
321 on("ui.render", { component: "ToolProgress" }, async ($, event, next) => {
322 const turn = await readTurnState($);
323
324 if (isToolShown(turn, event.props.tool_use_id)) {
325 return next(event);
326 }
327
328 const { Box } = $.ui.resolve(event);
329
330 return <Box />;
331 });
332
333 on("ui.render", { component: "TurnDuration" }, ($, event, next) => {
334 if (event.props.isExpanded === true) {
335 return next(event);
336 }
337
338 const { Box } = $.ui.resolve(event);
339
340 return <Box />;
341 });
342
343 on("ui.render", { component: "InfoNotice" }, async ($, event, next) => {
344 const turn = await readTurnState($);
345
346 if (isInfoNoticeShown(turn)) {
347 return next(event);
348 }
349
350 const { Box } = $.ui.resolve(event);
351
352 return <Box />;
353 });
354};
355hooks/copyButton.ts 165 lines1import type { EngineInterface, RenderSurface, Timer } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { CopyResult } from "../types";
5import { COLOR } from "./colors";
6
7const copyResultsState = atom(
8 { plugin: "ui-plus", key: "copyResults" } as const,
9 {},
10);
11
12const COPY_RESULT_MS = 3000;
13// ボタンの外側の字下げ 2 と、枠線 2 と、折り返さないための余裕 2
14const LABEL_WIDTH_OFFSET = 6;
15// 行末の通常の空白は描画時に削られるため、削られないノーブレークスペースで埋める
16const NO_BREAK_SPACE = " ";
17
18const COPY_LABELS = {
19 table: {
20 idle: "COPY TABLE",
21 success: "TABLE COPIED",
22 failed: "COPY FAILED",
23 },
24 code: {
25 idle: "COPY CODE",
26 success: "CODE COPIED",
27 failed: "COPY FAILED",
28 },
29} as const;
30
31const COPY_COLORS = {
32 idle: { color: COLOR.white, hoverColor: COLOR.indigo },
33 success: { color: COLOR.green, hoverColor: COLOR.green },
34 failed: { color: COLOR.red, hoverColor: COLOR.red },
35} as const;
36
37const resultClearTimers = new Map<string, Timer>();
38
39/**
40 * コピー対象の種類とコピーの結果に対応する、ボタンの文言と色を返す
41 * @param kind コピー対象の種類 (表またはコード)
42 * @param result コピーの結果。まだ押されていない場合は undefined または省略
43 * @returns 項目の label と color と hoverColor (ポインタが乗ったときの色)
44 */
45export function getCopyButtonStyle(
46 kind: "table" | "code",
47 result: CopyResult | "idle" = "idle",
48) {
49 return { label: COPY_LABELS[kind][result], ...COPY_COLORS[result] };
50}
51
52/**
53 * 文言を中央に置き、端末の横幅いっぱいになるまで両側を埋める
54 * 横幅が分からない場合は、文言をそのまま返す
55 * @param label ボタンの文言
56 * @param columns 端末の桁数。分からない場合は undefined
57 * @returns 横幅いっぱいにした文言
58 */
59export function padLabelToWidth(label: string, columns: number | undefined) {
60 if (columns === undefined) {
61 return label;
62 }
63
64 const width = Math.max(label.length, columns - LABEL_WIDTH_OFFSET);
65 const left = Math.floor((width - label.length) / 2);
66
67 return (
68 NO_BREAK_SPACE.repeat(left) +
69 label +
70 NO_BREAK_SPACE.repeat(width - label.length - left)
71 );
72}
73
74/**
75 * 保存済みのコピー結果を読む
76 * 描画の中で呼ぶと、copyText による更新時に再描画される
77 * @param $ engineのインターフェース
78 * @returns コピー対象の識別子ごとのコピー結果。押されていないものは含まれない
79 */
80export function readCopyResults($: EngineInterface) {
81 return read($, copyResultsState);
82}
83
84/**
85 * コピー結果を、保存済みの結果から消す
86 * @param $ engineのインターフェース
87 * @param id コピー対象の識別子
88 */
89async function clearCopyResult($: EngineInterface, id: string) {
90 resultClearTimers.delete(id);
91 await update($, copyResultsState, (results) =>
92 Object.fromEntries(Object.entries(results).filter(([key]) => key !== id)),
93 );
94}
95
96/**
97 * コピー結果を消すタイマーを取り消す
98 * @param id コピー対象の識別子
99 */
100function cancelCopyResultClear(id: string) {
101 resultClearTimers.get(id)?.cancel();
102 resultClearTimers.delete(id);
103}
104
105/**
106 * 一定時間後に、コピー結果を消すタイマーを登録する
107 * @param $ engineのインターフェース
108 * @param id コピー対象の識別子
109 */
110function scheduleCopyResultClear($: EngineInterface, id: string) {
111 resultClearTimers.set(
112 id,
113 $.clock.after(COPY_RESULT_MS, () => clearCopyResult($, id)),
114 );
115}
116
117/**
118 * コピー結果を保存し、一定時間後に消す
119 * @param $ engineのインターフェース
120 * @param id コピー対象の識別子
121 * @param result コピーの結果
122 */
123async function storeCopyResult(
124 $: EngineInterface,
125 id: string,
126 result: CopyResult,
127) {
128 cancelCopyResultClear(id);
129 await update($, copyResultsState, (results) => ({
130 ...results,
131 [id]: result,
132 }));
133 scheduleCopyResultClear($, id);
134}
135
136/**
137 * 文字列をクリップボードへコピーし、結果をボタンに反映する
138 * @param $ engineのインターフェース
139 * @param id コピー対象の識別子
140 * @param textToCopy コピーする文字列 (表の Markdown またはコード)
141 * @param surface ボタンが押された描画先
142 */
143export async function copyText(
144 $: EngineInterface,
145 id: string,
146 textToCopy: string,
147 surface: RenderSurface,
148) {
149 let result: CopyResult = "failed";
150
151 try {
152 const copied = await $.ui.copy({ text: textToCopy, surface });
153
154 if (copied.isCopied) {
155 result = "success";
156 } else {
157 $.ui.log(`copy failed: ${copied.reason}`, { to: "debug" });
158 }
159 } catch (error) {
160 $.ui.log(`copy failed: ${String(error)}`, { to: "debug" });
161 }
162
163 await storeCopyResult($, id, result);
164}
165hooks/colors.ts 16 lines1// claude codeのtheme keyに別名を付けて再利用することで既存の配色と一致させる
2// orangeを除き、基本的には新しいcolor codeは定義しない
3// mod側で利用されていないtheme keyもあるが、後でcolorを重複して定義しないために残しておく
4export const COLOR = {
5 green: "success",
6 blue: "planMode",
7 indigo: "suggestion",
8 yellow: "warning",
9 red: "error",
10 purple: "autoAccept",
11 white: "inactive",
12 grey: "text",
13 black: "inverseText",
14 orange: "#ff8700",
15} as const;
16hooks/foldState.ts 185 lines1import type { EngineInterface } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import { countNonEmptyLines, firstLineOf } from "./foldText";
5
6// 用語
7// | 日本語 | 意味 |
8// | -------------- | ---------------------------------------------------------------------- |
9// | チャット | 質問1つと、それに対する回答の全体。メインの1ターンに当たる |
10// | 回答のまとまり | チャットの回答のブロックすべて。折りたたみの開閉は、まとまりごと |
11// | 直近 | 最後の2チャットの回答のまとまりと、最後に表示した2件の質問 |
12// | 手動の開閉 | ボタンを押して開いた、または閉じた状態。直近かどうかより優先する |
13
14/**
15 * 回答のブロック1つの、折りたたみに必要な情報
16 */
17export interface FoldBlock {
18 id: string;
19 lineCount: number;
20 firstLine: string;
21}
22
23/**
24 * 回答のまとまり。ターンが分からないブロックは、そのブロックだけで1つのまとまりにする
25 */
26export interface FoldGroup {
27 key: string;
28 blocks: FoldBlock[];
29}
30
31const foldOverridesState = atom(
32 { plugin: "ui-plus", key: "foldOverrides" } as const,
33 {},
34);
35
36const RECENT_COUNT = 2;
37const MAX_OVERRIDES = 500;
38
39// 直近かどうかは描画時に初めて見た行で判定するため、再読み込み後は表示済みの行をすべて過去として扱う
40const groupOfBlock = new Map<string, FoldGroup>();
41const seenQuestionIds = new Set<string>();
42const recentGroupKeys: string[] = [];
43const recentQuestionIds: string[] = [];
44let currentTurn: FoldGroup | undefined;
45
46/**
47 * 一覧の末尾に追加し、直近の件数を超えた古いものを捨てる
48 * @param list 直近のものの一覧
49 * @param item 追加するもの
50 */
51function keepRecent<T>(list: T[], item: T) {
52 list.push(item);
53 list.splice(0, Math.max(0, list.length - RECENT_COUNT));
54}
55
56/**
57 * 回答のまとまりを折りたたむかを返す
58 * @param group 回答のまとまり
59 * @returns まとまり全体で、空でない行が2行以上なら true
60 */
61export function isGroupFoldable(group: FoldGroup) {
62 return group.blocks.reduce((sum, block) => sum + block.lineCount, 0) > 1;
63}
64
65/**
66 * ブロックが、回答のまとまりの最初のブロックかを返す
67 * @param group 回答のまとまり
68 * @param blockId 回答のブロックの識別子
69 * @returns 最初のブロックなら true
70 */
71export function isFirstBlock(group: FoldGroup, blockId: string) {
72 return group.blocks[0]?.id === blockId;
73}
74
75/**
76 * 閉じたときに表示する1行目を返す
77 * @param group 回答のまとまり
78 * @returns 最初のブロックの1行目
79 */
80export function headingOf(group: FoldGroup) {
81 return group.blocks[0]?.firstLine ?? "";
82}
83
84/**
85 * ターンが始まったことを記録し、折りたたみを描画し直す
86 * @param $ engineのインターフェース
87 * @param turnId ターンの識別子
88 */
89export function startAnswerTurn($: EngineInterface, turnId: string) {
90 currentTurn = { key: turnId, blocks: [] };
91 keepRecent(recentGroupKeys, turnId);
92 $.ui.invalidate("ui.render");
93}
94
95/**
96 * 回答のブロックを、今のターンの回答のまとまりに記録する
97 * 初めて見たブロック、またはまとまりを折りたたむかが変わったときは、描画し直す
98 * @param $ engineのインターフェース
99 * @param blockId 回答のブロックの識別子
100 * @param text ブロックの文章
101 * @returns ブロックが属する回答のまとまり
102 */
103export function noteAnswerBlock(
104 $: EngineInterface,
105 blockId: string,
106 text: string,
107) {
108 const known = groupOfBlock.get(blockId);
109 const group = known ?? currentTurn ?? { key: blockId, blocks: [] };
110 const wasFoldable = isGroupFoldable(group);
111 const info = {
112 lineCount: countNonEmptyLines(text),
113 firstLine: firstLineOf(text),
114 };
115 const block = group.blocks.find((one) => one.id === blockId);
116
117 if (block === undefined) {
118 group.blocks.push({ id: blockId, ...info });
119 groupOfBlock.set(blockId, group);
120 } else {
121 Object.assign(block, info);
122 }
123
124 if (block === undefined || wasFoldable !== isGroupFoldable(group)) {
125 $.ui.invalidate("ui.render");
126 }
127
128 return group;
129}
130
131/**
132 * 質問を、直近の質問として記録し、折りたたみを描画し直す
133 * 一度見た質問は記録しない
134 * @param $ engineのインターフェース
135 * @param id 質問の識別子
136 */
137export function noteQuestionSeen($: EngineInterface, id: string) {
138 if (seenQuestionIds.has(id)) {
139 return;
140 }
141
142 seenQuestionIds.add(id);
143 keepRecent(recentQuestionIds, id);
144 $.ui.invalidate("ui.render");
145}
146
147/**
148 * 質問または回答のまとまりを開いて表示するかを返す
149 * 手動の開閉があればそれを使い、なければ直近のものだけ開く
150 * 描画の中で呼ぶと、toggleOpen による更新時に再描画される
151 * @param $ engineのインターフェース
152 * @param key 質問の識別子、または回答のまとまりの識別子
153 * @returns 開くなら true
154 */
155export async function isOpen($: EngineInterface, key: string) {
156 const overrides = await read($, foldOverridesState);
157
158 return (
159 overrides[key] ??
160 (recentGroupKeys.includes(key) || recentQuestionIds.includes(key))
161 );
162}
163
164/**
165 * 手動の開閉を保存する。保存は上限の件数までで、古いものから捨てる
166 * @param $ engineのインターフェース
167 * @param key 質問の識別子、または回答のまとまりの識別子
168 * @param wasOpen 押す前に開いていたか
169 */
170export async function toggleOpen(
171 $: EngineInterface,
172 key: string,
173 wasOpen: boolean,
174) {
175 await update($, foldOverridesState, (overrides) => {
176 const entries: [string, boolean][] = Object.entries(overrides).filter(
177 ([name]) => name !== key,
178 );
179
180 return Object.fromEntries(
181 [...entries, [key, !wasOpen] as [string, boolean]].slice(-MAX_OVERRIDES),
182 );
183 });
184}
185hooks/foldText.ts 36 lines1const HEADING_OR_LIST_MARKER = /^\s*(#+|[-*]|\d+\.)\s+/;
2const INLINE_MARKER = /\*\*|__|`/g;
3
4/**
5 * 空でない行の数を返す
6 * @param text 質問または回答の文章
7 * @returns 空でない行の数
8 */
9export function countNonEmptyLines(text: string) {
10 return text.split("\n").filter((line) => line.trim().length > 0).length;
11}
12
13/**
14 * 空でない行が2行以上あるかを返す
15 * @param text 質問または回答の文章
16 * @returns 2行以上なら true。1行の文章は折りたたまない
17 */
18export function isMultiline(text: string) {
19 return countNonEmptyLines(text) > 1;
20}
21
22/**
23 * 折りたたんだときに表示する1行目を返す
24 * 最初の空でない行から、見出し、箇条書き、強調、バッククォートの記号を外す
25 * @param text 質問または回答の文章
26 * @returns 記号を外した1行目。空でない行がなければ空文字
27 */
28export function firstLineOf(text: string) {
29 const line = text.split("\n").find((one) => one.trim().length > 0) ?? "";
30
31 return line
32 .replace(HEADING_OR_LIST_MARKER, "")
33 .replaceAll(INLINE_MARKER, "")
34 .trim();
35}
36hooks/labelWidth.ts 82 lines1// ボタンの外側の字下げ 2 と、折り返さないための余裕 2
2export const ANSWER_TOGGLE_RESERVED = 4;
3// 折り返さないための余裕 2
4export const QUESTION_TOGGLE_RESERVED = 2;
5
6// 行末の通常の空白は描画時に削られるため、削られないノーブレークスペースで埋める
7const NO_BREAK_SPACE = " ";
8const WIDE_RANGES = [
9 [0x1100, 0x115f],
10 [0x2e80, 0xa4cf],
11 [0xac00, 0xd7a3],
12 [0xf900, 0xfaff],
13 [0xfe30, 0xfe6f],
14 [0xff00, 0xff60],
15 [0xffe0, 0xffe6],
16 [0x1f300, 0x1faff],
17] as const;
18const ZERO_WIDTH_RANGES = [
19 [0x0300, 0x036f],
20 [0x200b, 0x200d],
21 [0xfe0f, 0xfe0f],
22] as const;
23
24/**
25 * 符号位置が、範囲のどれかに含まれるかを返す
26 * @param codePoint 文字の符号位置
27 * @param ranges 先頭と末尾の符号位置の組
28 * @returns 含まれるなら true
29 */
30function isInRanges(
31 codePoint: number,
32 ranges: readonly (readonly [number, number])[],
33) {
34 return ranges.some(
35 ([first, last]) => codePoint >= first && codePoint <= last,
36 );
37}
38
39/**
40 * 端末での表示の幅を返す
41 * 全角の文字と絵文字は2、結合文字と幅のない文字は0、それ以外は1と数える
42 * @param text 幅を数える文字列
43 * @returns 表示の幅 (桁数)
44 */
45export function displayWidthOf(text: string) {
46 let width = 0;
47
48 for (const character of text) {
49 const codePoint = character.codePointAt(0) ?? 0;
50
51 if (isInRanges(codePoint, ZERO_WIDTH_RANGES)) {
52 continue;
53 }
54
55 width += isInRanges(codePoint, WIDE_RANGES) ? 2 : 1;
56 }
57
58 return width;
59}
60
61/**
62 * 文言の右側を、端末の横幅いっぱいになるまで埋める
63 * 横幅が分からない場合と、文言が余白を除いた横幅を超える場合は、文言をそのまま返す
64 * @param label ボタンの文言
65 * @param columns 端末の桁数。分からない場合は undefined
66 * @param reservedColumns 文言の外側で使う桁数と、折り返さないための余裕の合計
67 * @returns 右側を埋めた文言
68 */
69export function padLabelToRight(
70 label: string,
71 columns: number | undefined,
72 reservedColumns: number,
73) {
74 if (columns === undefined) {
75 return label;
76 }
77
78 const shortage = columns - reservedColumns - displayWidthOf(label);
79
80 return shortage > 0 ? label + NO_BREAK_SPACE.repeat(shortage) : label;
81}
82hooks/splitCopyTargetsFromAssistantMessage.ts 101 lines1import { findCodeBlockAt } from "./findCodeBlockAt";
2import { findTableAt } from "./findTableAt";
3
4// 表とコードブロックの用語は、findTableAt.ts と findCodeBlockAt.ts を参照
5// ドメイン用語
6// | 日本語 | 英語 | 定義 |
7// | ---------- | ---------------- | -------------------------------------------------------------- |
8// | 回答 | assistantMessage | 生成AIの回答の Markdown 全体 |
9// | 行 | line | 回答を改行で分けた1行 |
10// | 回答の部分 | messagePart | 回答を、通常の文章、表、コードに分けたときの1つ分 |
11// | 部分の種類 | kind | 部分が通常の文章 (plain)、表 (table)、コード (code) かを示す値 |
12// | 通常の文章 | plainText | 表とコードブロックを含まない部分の Markdown |
13// | コピー対象 | copyTarget | ボタンでコピーできる部分。表とコードブロック |
14
15export type MessagePart =
16 | { readonly kind: "plain"; readonly plainText: string }
17 | { readonly kind: "table"; readonly tableText: string }
18 | {
19 readonly kind: "code";
20 readonly codeText: string;
21 readonly codeBlockText: string;
22 };
23
24/**
25 * 先頭と末尾の空行を取り除く
26 * @param lines 通常の部分の行
27 * @returns 空行を取り除いた行
28 */
29function trimBlankLines(lines: string[]) {
30 let start = 0;
31 let end = lines.length;
32
33 while (start < end && lines[start]?.trim() === "") {
34 start += 1;
35 }
36
37 while (end > start && lines[end - 1]?.trim() === "") {
38 end -= 1;
39 }
40
41 return lines.slice(start, end);
42}
43
44/**
45 * 回答を、通常の部分と表とコードに分ける
46 * コードブロックを先に判定し、コードブロックの中の表は表にしない。中身が空のコードブロックは通常の部分にする
47 * @param assistantMessage 回答の Markdown
48 * @returns 回答の順に並んだ、通常の文章と表とコード
49 */
50export function splitCopyTargetsFromAssistantMessage(assistantMessage: string) {
51 const lines = assistantMessage.replace(/\r\n?/g, "\n").split("\n");
52 const messageParts: MessagePart[] = [];
53 let plainLines: string[] = [];
54 let index = 0;
55
56 const pushPlainPart = () => {
57 const plainText = trimBlankLines(plainLines).join("\n");
58 plainLines = [];
59
60 if (plainText.trim() !== "") {
61 messageParts.push({ kind: "plain", plainText });
62 }
63 };
64
65 while (index < lines.length) {
66 const codeBlock = findCodeBlockAt(lines, index);
67
68 if (codeBlock !== undefined) {
69 if (codeBlock.codeText.trim() !== "") {
70 pushPlainPart();
71 messageParts.push({
72 kind: "code",
73 codeText: codeBlock.codeText,
74 codeBlockText: codeBlock.codeBlockLines.join("\n"),
75 });
76 } else {
77 plainLines.push(...codeBlock.codeBlockLines);
78 }
79
80 index = codeBlock.nextIndex;
81 continue;
82 }
83
84 const table = findTableAt(lines, index);
85
86 if (table !== undefined) {
87 pushPlainPart();
88 messageParts.push({ kind: "table", tableText: table.tableText });
89 index = table.nextIndex;
90 continue;
91 }
92
93 plainLines.push(lines[index] ?? "");
94 index += 1;
95 }
96
97 pushPlainPart();
98
99 return messageParts;
100}
101hooks/turnVisibility.ts 121 lines1import type { EngineInterface } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4import type { TurnState } from "../types";
5
6// 用語
7// | 日本語 | 英語 | 意味 |
8// | ------------------ | ----------- | ------------------------------------------------------------ |
9// | 回答中 | isRunning | メインの回答の開始から終了までの間 |
10// | 回答の終了後 | hasEnded | 1回以上、メインの回答が終わっている状態 |
11// | 今の回答のツール | toolUseIds | 回答中に呼ばれたツールの識別子。サブエージェントの呼び出しも含む |
12// | サブエージェントの行 | - | 他のエージェントからのメッセージと、バックグラウンドタスクの通知 |
13
14// 描画の識別子で覚える。終わった回答までに描画した行は、次の回答中も表示に戻さない
15const seenAgentRowIds = new Set<string>();
16const endedAgentRowIds = new Set<string>();
17
18const turnState = atom({ plugin: "ui-plus", key: "turn" } as const, {
19 isRunning: false,
20 hasEnded: false,
21 toolUseIds: [],
22});
23
24/**
25 * 保存済みの回答の状態を読む
26 * 描画の中で呼ぶと、回答の開始、ツールの呼び出し、回答の終了による更新時に再描画される
27 * @param $ engineのインターフェース
28 * @returns 回答の状態
29 */
30export function readTurnState($: EngineInterface) {
31 return read($, turnState);
32}
33
34/**
35 * 回答の開始を保存する。前の回答のツールは忘れる
36 * @param $ engineのインターフェース
37 */
38export async function startTurn($: EngineInterface) {
39 await update($, turnState, (turn) => ({
40 ...turn,
41 isRunning: true,
42 toolUseIds: [],
43 }));
44}
45
46/**
47 * 今の回答で呼ばれたツールを保存する
48 * @param $ engineのインターフェース
49 * @param toolUseId ツール呼び出しの識別子
50 */
51export async function recordToolCall($: EngineInterface, toolUseId: string) {
52 await update($, turnState, (turn) => ({
53 ...turn,
54 toolUseIds: [...turn.toolUseIds, toolUseId],
55 }));
56}
57
58/**
59 * 回答の終了を保存する。今の回答のツールは忘れる
60 * @param $ engineのインターフェース
61 */
62export async function endTurn($: EngineInterface) {
63 for (const id of seenAgentRowIds) {
64 endedAgentRowIds.add(id);
65 }
66
67 await update($, turnState, () => ({
68 isRunning: false,
69 hasEnded: true,
70 toolUseIds: [],
71 }));
72}
73
74/**
75 * ツールの行を、今の回答のものとして表示するかを返す
76 * @param turn 回答の状態
77 * @param toolUseId ツール呼び出しの識別子
78 * @returns 今の回答のツールなら true
79 */
80export function isToolShown(turn: TurnState, toolUseId: string) {
81 return turn.toolUseIds.includes(toolUseId);
82}
83
84/**
85 * ツールのまとめの行を、今の回答のものとして表示するかを返す
86 * @param turn 回答の状態
87 * @param calls まとめに含まれるツール呼び出し
88 * @returns 1つでも今の回答のツールがあれば true
89 */
90export function isToolGroupShown(
91 turn: TurnState,
92 calls: readonly { tool_use_id?: string }[],
93) {
94 return calls.some(
95 (call) =>
96 call.tool_use_id !== undefined && isToolShown(turn, call.tool_use_id),
97 );
98}
99
100/**
101 * サブエージェントの行を、今の回答のものとして表示するかを返す
102 * 回答が始まる前に描画した行も、その回答が始まれば表示する
103 * @param turn 回答の状態
104 * @param rowId 描画する行の識別子
105 * @returns 回答中で、前の回答までに描画していない行なら true
106 */
107export function isAgentRowShown(turn: TurnState, rowId: string) {
108 seenAgentRowIds.add(rowId);
109
110 return turn.isRunning && !endedAgentRowIds.has(rowId);
111}
112
113/**
114 * 起動時のお知らせを表示するかを返す
115 * @param turn 回答の状態
116 * @returns 最初の回答が終わるまで、または回答中なら true
117 */
118export function isInfoNoticeShown(turn: TurnState) {
119 return !turn.hasEnded || turn.isRunning;
120}
121hooks/findCodeBlockAt.ts 80 lines1// ドメイン用語
2// | 日本語 | 英語 | 定義 |
3// | -------------- | --------- | ---------------------------------------------------------- |
4// | コードブロック | codeBlock | フェンス行で囲まれたコードのひとまとまり。フェンス行を含む |
5// | フェンス行 | fenceLine | コードブロックを囲む行。開始と終了の2つがある |
6// | コード | codeText | コードブロックからフェンス行を除いた中身。コピーの対象 |
7
8/**
9 * 指定した行から始まるコードブロックの検出結果
10 */
11export interface CodeBlockMatch {
12 /** フェンス行を含むコードブロックの行 */
13 readonly codeBlockLines: string[];
14 /** フェンス行を除いたコードの中身 */
15 readonly codeText: string;
16 /** 終了のフェンス行の1つ後ろの行の位置。呼び出し元が次に読む位置 */
17 readonly nextIndex: number;
18}
19
20const OPENING_FENCE_LINE = /^ {0,3}(?:(`{3,})[^`]*|(~{3,}).*)$/;
21const CLOSING_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
22
23/**
24 * コードブロックの終了のフェンス行の位置を探す
25 * 終了のフェンス行は、開始と同じ文字で、開始以上の長さの行
26 * @param lines 回答の行
27 * @param openingIndex 開始のフェンス行の位置
28 * @param openingFence 開始のフェンス行のバッククォートまたはチルダの並び
29 * @returns 終了のフェンス行の位置。閉じられていない場合は undefined
30 */
31function findClosingFenceIndex(
32 lines: string[],
33 openingIndex: number,
34 openingFence: string,
35) {
36 for (let index = openingIndex + 1; index < lines.length; index += 1) {
37 const closingFence = CLOSING_FENCE_LINE.exec(lines[index] ?? "")?.[1];
38
39 if (
40 closingFence !== undefined &&
41 closingFence.startsWith(openingFence.charAt(0)) &&
42 closingFence.length >= openingFence.length
43 ) {
44 return index;
45 }
46 }
47
48 return undefined;
49}
50
51/**
52 * 指定した行からコードブロックが始まる場合に、そのコードブロックを取り出す
53 * 開始のフェンス行に書かれた言語は区別しない
54 * 終了のフェンス行がない場合は、回答の最後までをコードブロックとする
55 * @param lines 回答の行
56 * @param index コードブロックが始まるかを調べる行の位置
57 * @returns コードブロックの行とコードと次に読む行の位置。始まらない場合は undefined
58 */
59export function findCodeBlockAt(
60 lines: string[],
61 index: number,
62): CodeBlockMatch | undefined {
63 const opening = OPENING_FENCE_LINE.exec(lines[index] ?? "");
64 const openingFence = opening?.[1] ?? opening?.[2];
65
66 if (openingFence === undefined) {
67 return undefined;
68 }
69
70 const closingIndex = findClosingFenceIndex(lines, index, openingFence);
71 const nextIndex =
72 closingIndex === undefined ? lines.length : closingIndex + 1;
73
74 return {
75 codeBlockLines: lines.slice(index, nextIndex),
76 codeText: lines.slice(index + 1, closingIndex ?? lines.length).join("\n"),
77 nextIndex,
78 };
79}
80hooks/findTableAt.ts 146 lines1// ドメイン用語
2// | 日本語 | 英語 | 定義 |
3// | ---------- | ------------- | ------------------------------------------------ |
4// | 表 | table | 見出し行、区切り行、表の行が続くひとまとまり |
5// | 表の文字列 | tableText | 表の元の Markdown。コピーの対象 |
6// | 見出し行 | headerLine | 表の1行目。列の名前が並ぶ |
7// | 区切り行 | separatorLine | 表の2行目。--- が並ぶ |
8// | 表の行 | row | 表の中の1行 (見出し行、区切り行、データ行を含む) |
9// | 列 | column | 表の縦の区切り。縦線で区切られる |
10
11/**
12 * 指定した行から始まる表の検出結果
13 */
14export interface TableMatch {
15 /** 表の元の Markdown */
16 readonly tableText: string;
17 /** 表の最後の行の、1つ後ろの行の位置。呼び出し元が次に読む位置 */
18 readonly nextIndex: number;
19}
20
21const SEPARATOR_CELL = /^:?-+:?$/;
22const NON_CODE_INDENT_START = /^ {0,3}\S/;
23
24/**
25 * 行を、エスケープされていない | で区切る
26 * バックスラッシュの直後の1文字は区切りにしないため、\\| の | は区切りになる
27 * @param row 表の1行
28 * @returns 区切った文字列。先頭と末尾の | による空の要素も含む
29 */
30function splitRow(row: string) {
31 const pieces: string[] = [];
32 let current = "";
33 let isEscaped = false;
34
35 for (const char of row) {
36 if (isEscaped) {
37 current += char;
38 isEscaped = false;
39 } else if (char === "\\") {
40 current += char;
41 isEscaped = true;
42 } else if (char === "|") {
43 pieces.push(current);
44 current = "";
45 } else {
46 current += char;
47 }
48 }
49
50 pieces.push(current);
51
52 return pieces;
53}
54
55/**
56 * 表の1行のセルを取り出す
57 * 先頭と末尾の | による空の要素は含めない
58 * @param row 表の1行
59 * @returns セルの文字列
60 */
61function cellsOf(row: string) {
62 const cells = splitRow(row.trim());
63
64 if (cells.length > 1 && cells[0] === "") {
65 cells.shift();
66 }
67
68 if (cells.length > 1 && cells.at(-1) === "") {
69 cells.pop();
70 }
71
72 return cells;
73}
74
75/**
76 * 行が表の区切り行かを判定する
77 * @param line 判定する行
78 * @returns すべてのセルが -- や :-: の形の場合は true
79 */
80function isSeparatorRow(line: string) {
81 return cellsOf(line).every((cell) => SEPARATOR_CELL.test(cell.trim()));
82}
83
84/**
85 * 表の1行の列数を数える
86 * 先頭と末尾の | と、エスケープされた | は区切りに含めない
87 * @param row 表の1行
88 * @returns 列数
89 */
90function countColumns(row: string) {
91 return cellsOf(row).length;
92}
93
94/**
95 * 行が表の行かを判定する
96 * @param line 判定する行
97 * @returns | を含む空でない行の場合は true
98 */
99function isTableRow(line: string) {
100 return line.includes("|") && line.trim() !== "";
101}
102
103/**
104 * 見出し行と区切り行で、表が始まるかを判定する
105 * 区切り行だけでは水平線と区別できないため、列数が見出し行と一致するときだけ表とする
106 * @param headerLine 見出し行の候補
107 * @param separatorLine 区切り行の候補。次の行がない場合は undefined
108 * @returns 表が始まる場合は true
109 */
110function isTableStart(headerLine: string, separatorLine: string | undefined) {
111 return (
112 NON_CODE_INDENT_START.test(headerLine) &&
113 splitRow(headerLine).length > 1 &&
114 separatorLine !== undefined &&
115 isSeparatorRow(separatorLine) &&
116 countColumns(headerLine) === countColumns(separatorLine)
117 );
118}
119
120/**
121 * 指定した行から表が始まる場合に、その表を取り出す
122 * 表の行は、空行か | のない行で終わる
123 * @param lines 回答の行
124 * @param index 表が始まるかを調べる行の位置
125 * @returns 表の文字列と次に読む行の位置。表が始まらない場合は undefined
126 */
127export function findTableAt(
128 lines: string[],
129 index: number,
130): TableMatch | undefined {
131 if (!isTableStart(lines[index] ?? "", lines[index + 1])) {
132 return undefined;
133 }
134
135 let tableEnd = index + 2;
136
137 while (tableEnd < lines.length && isTableRow(lines[tableEnd] ?? "")) {
138 tableEnd += 1;
139 }
140
141 return {
142 tableText: lines.slice(index, tableEnd).join("\n"),
143 nextIndex: tableEnd,
144 };
145}
146types/index.d.ts 18 lines1export type CopyResult = "success" | "failed";
2
3export interface TurnState {
4 readonly isRunning: boolean;
5 readonly hasEnded: boolean;
6 readonly toolUseIds: readonly string[];
7}
8
9declare module "claude-code" {
10 interface PluginState {
11 "ui-plus": {
12 readonly copyResults: Readonly<Record<string, CopyResult>>;
13 readonly turn: TurnState;
14 readonly foldOverrides: Readonly<Record<string, boolean>>;
15 };
16 }
17}
18