SLOPSHOPPER

ui-plus

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

newspinnerrowsguardtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ui-plus
fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

Claude Code Plugin

概要

metalmental (ユーザ名) の claude code の plugins 等をまとめたリポジトリ

plugin一覧

plugin名説明
ask-user-question-enforceSkillのユーザー確認をAskUserQuestionに統一させるSKILL
aws-best-practiceAWSリソースの推奨設定SKILL
career-transition-supportITエンジニア転職SKILL
code-review-by-book読んだ技術書をもとにしたコードレビューWorkflow
git-githubcommit、worktree/branch、pr作成Skill
local-doc-searchローカルPCのファイルを全文検索やRAG検索するMCP
sandbox-enforce全claude設定ファイルでsandbox.enabledにするSkill
session-daily-report全プロジェクトのセッション履歴から日報を作成するWorkflow
statuslineModelやBranch名、Python等のversionを表示するstatusline Mod
tool-failure-logToolの実行エラーを調査可能にするSkill
ui-plusUIに表のコピーや会話の折りたたみボタン追加するMod
verified-web-search検索したURLの完全なページを取得し、正確な1件のみ返すWorkflow

Add Marketplace

# Launch Claude
claude

# Add this marketplace
/plugin marketplace add https://github.com/Flupinochan/metalmental-claude

# Reload plugins
/reload-plugins

Install Plugins Example

/plugin install git-github@metalmental-plugins-official
/git-github:commit-enforce

Local Develop Example

claude --plugin-dir "$(pwd)/plugins/session-daily-report"

共通ルール (plugin作成/修正後にすること)

  • plugin.json の version を上げること
  • plugin-dev:plugin-validator や claude plugin validate, test, eval 実行して動作確認するかユーザに確認すること
  • docs/help-skill-sample.md を参考にして /help skillを作成すること
Source 11 files
hooks/register.tsx 355 lines
1import 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};
355
hooks/copyButton.ts 165 lines
1import 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}
165
hooks/colors.ts 16 lines
1// 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;
16
hooks/foldState.ts 185 lines
1import 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}
185
hooks/foldText.ts 36 lines
1const 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}
36
hooks/labelWidth.ts 82 lines
1// ボタンの外側の字下げ 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}
82
hooks/splitCopyTargetsFromAssistantMessage.ts 101 lines
1import { 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}
101
hooks/turnVisibility.ts 121 lines
1import 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}
121
hooks/findCodeBlockAt.ts 80 lines
1// ドメイン用語
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}
80
hooks/findTableAt.ts 146 lines
1// ドメイン用語
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}
146
types/index.d.ts 18 lines
1export 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