SLOPSHOPPER

todo-pane

Shows this session's todo list (what we are working on) and pin board (pinned notes) in side panes, one file per session under a directory

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-pane
│ ┃ TODO ✕ › fix the failing auth test and add an audit log call │ ┃ (No TODO yet) │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo-pane │ ⎿ todo-pane: Brought forward. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TODO
(No TODO yet)
Pane · Pins
(Nothing pinned yet)
README

todo-pane

日本語

A plugin that keeps Claude's current task list in a pane next to the conversation.

During a long session in Claude Code, side trips and confirmations make it easy to lose track of what is being done now and what is left. With todo-pane installed, Claude writes each piece of work to the task list before starting it, and when it finishes, checks it off and moves it under "Done (date)". Look at the right-hand pane at any time to see the current work and what remains.

Claude Code conversation on the left, a pane with "TODO" and "Pins" tabs on the right

The pane has two tabs.

  • TODO shows progress. Claude writes what it is doing now, what remains and what is done, and updates it as the work goes on. Things you are waiting to confirm or want to check later go here too, as part of the work.
  • Pins (「ピン留め」 in Japanese) holds reference material you want to look back at while working: an explanation Claude gave, a comparison table, a summary of what you decided. Claude writes to it only when asked to pin something, and does not update it as work progresses.

Some ways to use pins:

  • Ask Claude to explain how something works or walk through a procedure, then ask it to pin the answer. The answer stays beside the conversation instead of scrolling away.
  • Ask Claude to pin research results such as URLs, commands and setting values. You can check them in the pane when needed instead of asking again.

Both tabs are plain Markdown files, one file per session. When Claude rewrites a file, the pane updates right away.

The pane and the instructions to Claude are implemented by a mod (a function hook that runs inside Claude Code) in the plugin. Mods arrived in Claude Code 2.1.287, so 2.1.287 or later is required.

Install

/plugin marketplace add aromarious/claude-code-plugins
/plugin install todo-pane@aromarious

Right after installing, a "Configure todo-pane" screen appears. Skip it without entering anything and the files are created under the default .claude.

Commands

CommandKindWhat it does
/todo-paneMod commandOpens the TODO pane, or closes it if it is open. Opening creates the todo file if it is missing
/todo-pane-pinboardMod commandOpens the Pins pane, or closes it if it is open
/todo-pane-wrapMod commandSwitches long lines in the TODO pane between being cut with "…" (default) and wrapping onto the next row. The choice is kept across sessions
/todo-pane:pin [what to pin]SkillPins the main content of Claude's last reply. With text after it, pins what the text describes

The ✕ button at the top right of a pane also closes it. Run the command again to reopen it.

To toggle wrapping with a key, bind the command in ~/.claude/keybindings.json. Claude Code keybindings can run a slash command with the action "command:<name>":

{ "bindings": [ { "context": "Chat", "bindings": { "meta+z": "command:todo-pane-wrap" } } ] }

On macOS, Option+Z reaches Claude Code as Meta+Z only if the terminal sends it that way. In Ghostty, add keybind = alt+z=esc:z to its config (and keybind = cmd+alt+z=esc:z to also use Cmd+Alt+Z). The command: action is not in the official keybindings documentation (it was found in Claude Code itself), so it may change.

When the TODO pane is not visible

If Claude Code's own diff pane (the pane that shows git changes) is open, the TODO pane is hidden behind it, and running /todo-pane does not bring it forward. If the right side shows a list of changed files or "Diff unavailable", that is the diff pane. Run /diff or click the ✕ at its top right to close it.

The diff pane can open by itself in a git repository when the screen is wide. Once you close it with /diff or ✕, that state is saved as diffSidebarOpen in ~/.claude.json and it no longer opens automatically.

Components

ComponentUsedFilesRole
Function hook (mod)Yesmodules in hooks/hooks.json, hooks/register.tsxDraws the panes, adds instructions for Claude on every prompt, detects a missed update, provides the /todo-pane and /todo-pane-pinboard commands
Setting (userConfig)Yes.claude-plugin/plugin.jsonLets you change the directory dir where files are kept (Settings)
CommandsYeshooks/register.tsx/todo-pane and /todo-pane-pinboard. Not command files; the mod registers them at startup
SkillYesskills/pin/SKILL.md/todo-pane:pin, which pins something to the Pins pane
Shell command hookNo——
Command files, agents, MCP serversNo——

Claude Code has two kinds of hooks: hooks that run a shell command when an event occurs, and hooks that run a function inside Claude Code (mods). todo-pane uses function hooks only. hooks/hooks.json just tells Claude Code to load the module that holds them (register.tsx).

The mod registers functions for these events.

EventWhat it does
session.startDetects the display language, opens the panes, starts re-reading the files every 3 seconds, and last registers /todo-pane and /todo-pane-pinboard (a name already taken does not stop the rest)
prompt.submitOn every prompt, attaches instructions for Claude that match the current state
turn.startRemembers the contents of the todo file at the start of the turn
tool.callRecords whether a tool that changes files or Notion was called
turn.completeIf something was changed but the todo file was not, shows a notice and a status line
command.runOn /todo-pane or /todo-pane-pinboard, opens the pane in front if it is closed, brings it forward if another tab is in front, and closes it if it is already in front. Opening with /todo-pane also creates the todo file if it is missing
ui.closeRemembers that a pane was closed (by a command or by ✕), so the next command opens it again
skill.promptWhen /todo-pane:pin runs, adds the session's pin file path to the skill text and opens the Pins pane
ui.renderDraws the pane contents (the Markdown of the todo file and the pin file)

External commands

The mod runs the following commands. All of them come with macOS and Linux. Windows has not been tested.

CommandPurpose
sh, ls, tailRead the last 500 lines of the session transcript (JSONL) to get the session name (every 3 seconds)
rmDelete the old-name files when the session name changes and the files are renamed

Files shown

One file of each kind is kept per session, under .claude/ relative to the directory where the session was started.

FilePane nameWhen it opens
.claude/todo/<name>.mdTODOAlways opens at session start
.claude/pin/<name>.mdPinsOpens at session start if the file exists
.claude/todo/done/<date>.md(no pane; the latest one is shown under the TODO tab)Where the previous day's done items are moved when the date changes
.claude/todo/<name>.off(no pane)Records that you answered "don't create" in this session

<name> is <session name>-<first 6 characters of the session ID> when the session has a name, and the first 8 characters of the session ID otherwise. /, \, : and control characters in the session name are replaced with _. Emoji and Japanese are kept as they are.

The pane re-reads the files every 3 seconds. A closed pane can be reopened with /todo-pane or /todo-pane-pinboard, and the ✕ button at the top right of a pane closes it, just like running the command again.

The session name comes from the latest custom-title line in the session transcript (JSONL). When the name changes mid-session, <name> is recomputed during the 3-second re-read, and the existing todo, pin and off files are moved to the new name.

When the date changes

The date in the todo file's "## Done (YYYY-MM-DD)" heading (in Japanese, "## やったこと(YYYY-MM-DD)") is today's date in local time. When the date changes, at the next prompt or pane re-read, the contents of the previous day's done section are moved to <dir>/todo/done/<date>.md and the todo file's heading is changed to today's date. In the destination file they go under a "## Session <name>" heading. There is one file per date, shared by the sessions in the same directory. The older "## 今日やったこと" heading is read as the dated heading. A done heading in any language is accepted, and the heading written afterwards is in the current language.

Under the TODO tab, a divider follows the todo file, then the completed items of the most recent earlier day, up to the last 5. If there are more than 5, a line "…and N more" with the location of the source file is added. This is display only; it is not written to the todo file.

Long lines in the TODO tab are cut with "…" to fit the pane's current width instead of wrapping, and the cut is recomputed when the width changes. The todo file keeps the full text, and the Pins tab still wraps.

Settings

One setting can be changed. It is a path relative to the working directory; an absolute path also works. A changed setting takes effect from the next session.

SettingDefaultMeaning
dir.claudeDirectory that holds todo/ and pin/

Write the value in pluginConfigs in ~/.claude/settings.json. The key is todo-pane@aromarious when installed from the marketplace, and todo-pane or todo-pane@inline when loaded with --plugin-dir.

{
  "pluginConfigs": {
    "todo-pane@aromarious": {
      "options": { "dir": "out" }
    }
  }
}

With this example, the files are out/todo/<name>.md, out/pin/<name>.md and out/todo/<name>.off.

Instructions to Claude

Every time you send a prompt, the following instructions are attached after it and passed to Claude. They are not shown on screen and the prompt text is not changed. They are rebuilt for every prompt, so they reflect the current state, such as whether the todo file exists or whether the session name changed.

The instructions are written in English. The headings in them follow Claude Code's language setting (see Display language): "## Now" and "## Done (date)" in English, "## 今" and "## やったこと(日付)" when the language is Japanese. The instructions also tell Claude to write the items in the language you are writing in, so talking to Claude in Japanese gives Japanese items. The instructions say:

  • The session's todo file is the TODO list shown in the right-hand pane.
  • Write every item as a checkbox: - [ ] for open, - [x] for done. Never use a plain - bullet.
  • Before starting any work that changes files or Notion, first write it under the "now" heading of the todo file.
  • When the work is done, check it and move it under the "done (today's date)" heading, and keep remaining items in their sections.
  • When the topic changes, update the "now" section to match.
  • Write the items in the language the user is writing in.

For pins, the following instruction is attached every time, whether or not the todo file exists and whether or not you answered "don't create".

  • When asked to pin something ("pin that", 「ピン留めして」), write the target (the explanation, comparison table, summary, etc. just given) as Markdown to the session's pin file, creating it if missing. This is the pin tab of this plugin's side pane, not claude.ai Artifact pinning. The instruction also mentions the /todo-pane:pin skill. Write only when asked; do not update it as work progresses.

If a turn changed files or Notion but the todo file did not change, a notice and a status line tell you, and Claude is told to bring the todo file up to date at the start of the next turn. Changes made by subagents are not counted.

Display language

Tab titles, todo-file headings, empty-pane placeholders, the previous-day block and notices follow Claude Code's language setting. If language names one of the supported languages below, they are in that language; otherwise they are in English. English names, native names and codes all work, in any case (German, Deutsch, de; Traditional Chinese, 繁體中文, zh-TW; pt-BR). A plain Chinese or 中文 is Simplified. The check runs once, at session start.

Supported languages: English, Japanese (日本語), Simplified Chinese (简体中文), Traditional Chinese (繁體中文), Korean (한국어), Spanish (Español), French (Français), German (Deutsch), Portuguese (Português), Italian (Italiano) and Russian (Русский). Other languages are welcome: please open an issue or a pull request. The strings are in the L table at the top of hooks/register.tsx.

The table shows the Japanese and English wording as examples.

ItemJapaneseEnglish
Now heading## 今## Now
Done heading## やったこと(date)## Done (date)
Pin tab titleピン留めPins
Previous-day heading#### 前の日(date)#### Previous day (date)
Done file title# date にやったこと# Done on date
Done file session heading## セッション <name>## Session <name>
Missed-update notice<file> が更新されていません<file> was not updated

The date rollover described above recognises the done heading of any supported language (## Done (YYYY-MM-DD), ## やったこと(YYYY-MM-DD), ## Erledigt (YYYY-MM-DD), and so on). Any other heading is left alone, even one that ends with a date, such as ## Meeting notes (2026-10-01). A newly written heading uses the current language.

When there is no todo file

In a session that has no todo file of its own, Claude asks in the first turn whether to create one. The question is asked once per session.

  • If you say yes, Claude creates the session's todo file with the "now" and "done (today's date)" headings (in the language from the table above).
  • If you say no, Claude creates an empty file <dir>/todo/<name>.off and does not ask again in this session.

In either case, running /todo-pane creates a todo file containing only the "## Now" and "## Done (today's date)" headings (Japanese equivalents when the language is Japanese) and shows it in the pane. It also deletes the .off file, so you can start using it with /todo-pane even after answering "don't create".

Source 2 files
hooks/register.tsx 582 lines
1import { atom, read, update } from 'claude-code'
2import type { PluginOptions, Register } from 'claude-code'
3
4// Claude rewrites these files; each pane shows whatever its file says.
5// Files live under the "dir" setting (relative to the session's working directory unless absolute):
6// <dir>/todo/<base>.md, <dir>/pin/<base>.md, and the off marker <dir>/todo/<base>.off. One set per session.
7const dirFrom = (options: PluginOptions) => {
8  const v = options.dir
9  return (typeof v === 'string' && v ? v : '.claude').replace(/\/+$/, '')
10}
11// Session name (control characters and path separators become _) plus the id's first 6 characters;
12// without a name, the id's first 8.
13export const sanitize = (name: string) => name.replace(/[\/\\:\u0000-\u001f\u007f]/g, '_')
14export const baseName = (title: string | undefined, id: string) => {
15  const t = title ? sanitize(title.trim()) : ''
16  return t ? `${t}-${id.slice(0, 6)}` : id.slice(0, 8)
17}
18export const pathsFor = (dir: string, base: string) => ({
19  todo: `${dir}/todo/${base}.md`,
20  pin: `${dir}/pin/${base}.md`,
21  off: `${dir}/todo/${base}.off`,
22})
23// The latest {"type":"custom-title"} line in the transcript's tail, if any
24export const lastTitle = (text: string): string | undefined => {
25  let title: string | undefined
26  for (const line of text.split('\n')) {
27    if (!line.includes('"custom-title"')) continue
28    try {
29      const j = JSON.parse(line)
30      if (j.type === 'custom-title' && typeof j.customTitle === 'string') title = j.customTitle
31    } catch {}
32  }
33  return title
34}
35// Local date as YYYY-MM-DD (toISOString would be UTC)
36export const today = () => {
37  const d = new Date()
38  const z = (n: number) => String(n).padStart(2, '0')
39  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`
40}
41
42// Fixed strings the person sees (headings, tab titles, placeholders, toasts). Prompts to Claude stay English.
43export type Lang = 'ja' | 'en' | 'zh-Hans' | 'zh-Hant' | 'ko' | 'es' | 'fr' | 'de' | 'pt' | 'it' | 'ru'
44export const L = {
45  ja: {
46    now: '## 今',
47    done: (d: string) => `## やったこと(${d})`,
48    todoTitle: 'TODO',
49    pinTitle: 'ピン留め',
50    emptyTodo: '(TODO はまだありません)',
51    emptyPin: '(ピン留めはまだありません)',
52    prevDay: (d: string) => `#### 前の日(${d})`,
53    more: (n: number, path: string) => `…ほか ${n} 件(${path})`,
54    doneTitle: (d: string) => `# ${d} にやったこと`,
55    session: (label: string) => `## セッション ${label}`,
56    missedToast: (f: string) => `${f} が更新されていません`,
57    missedStatus: (f: string) => `${f} 未更新`,
58  },
59  en: {
60    now: '## Now',
61    done: (d: string) => `## Done (${d})`,
62    todoTitle: 'TODO',
63    pinTitle: 'Pins',
64    emptyTodo: '(No TODO yet)',
65    emptyPin: '(Nothing pinned yet)',
66    prevDay: (d: string) => `#### Previous day (${d})`,
67    more: (n: number, path: string) => `…and ${n} more (${path})`,
68    doneTitle: (d: string) => `# Done on ${d}`,
69    session: (label: string) => `## Session ${label}`,
70    missedToast: (f: string) => `${f} was not updated`,
71    missedStatus: (f: string) => `${f} not updated`,
72  },
73  'zh-Hans': {
74    now: '## 现在',
75    done: (d: string) => `## 已完成(${d})`,
76    todoTitle: 'TODO',
77    pinTitle: '固定',
78    emptyTodo: '(暂无 TODO)',
79    emptyPin: '(暂无固定内容)',
80    prevDay: (d: string) => `#### 前一天(${d})`,
81    more: (n: number, path: string) => `…另有 ${n} 项(${path})`,
82    doneTitle: (d: string) => `# ${d} 已完成事项`,
83    session: (label: string) => `## 会话 ${label}`,
84    missedToast: (f: string) => `${f} 尚未更新`,
85    missedStatus: (f: string) => `${f} 未更新`,
86  },
87  'zh-Hant': {
88    now: '## 現在',
89    done: (d: string) => `## 已完成(${d})`,
90    todoTitle: 'TODO',
91    pinTitle: '釘選',
92    emptyTodo: '(尚無 TODO)',
93    emptyPin: '(尚無釘選內容)',
94    prevDay: (d: string) => `#### 前一天(${d})`,
95    more: (n: number, path: string) => `…另有 ${n} 項(${path})`,
96    doneTitle: (d: string) => `# ${d} 已完成事項`,
97    session: (label: string) => `## 工作階段 ${label}`,
98    missedToast: (f: string) => `${f} 尚未更新`,
99    missedStatus: (f: string) => `${f} 未更新`,
100  },
101  'ko': {
102    now: '## 지금',
103    done: (d: string) => `## 완료 (${d})`,
104    todoTitle: 'TODO',
105    pinTitle: '고정',
106    emptyTodo: '(TODO가 아직 없습니다)',
107    emptyPin: '(고정된 항목이 없습니다)',
108    prevDay: (d: string) => `#### 전날 (${d})`,
109    more: (n: number, path: string) => `…외 ${n}건 (${path})`,
110    doneTitle: (d: string) => `# ${d} 완료한 일`,
111    session: (label: string) => `## 세션 ${label}`,
112    missedToast: (f: string) => `${f} 업데이트되지 않았습니다`,
113    missedStatus: (f: string) => `${f} 업데이트 안 됨`,
114  },
115  'es': {
116    now: '## Ahora',
117    done: (d: string) => `## Hecho (${d})`,
118    todoTitle: 'TODO',
119    pinTitle: 'Fijados',
120    emptyTodo: '(Aún no hay TODO)',
121    emptyPin: '(Nada fijado todavía)',
122    prevDay: (d: string) => `#### Día anterior (${d})`,
123    more: (n: number, path: string) => `…y ${n} más (${path})`,
124    doneTitle: (d: string) => `# Hecho el ${d}`,
125    session: (label: string) => `## Sesión ${label}`,
126    missedToast: (f: string) => `${f} no se ha actualizado`,
127    missedStatus: (f: string) => `${f} sin actualizar`,
128  },
129  'fr': {
130    now: '## En cours',
131    done: (d: string) => `## Terminé (${d})`,
132    todoTitle: 'TODO',
133    pinTitle: 'Épinglés',
134    emptyTodo: '(Aucun TODO pour l’instant)',
135    emptyPin: '(Rien d’épinglé pour l’instant)',
136    prevDay: (d: string) => `#### Jour précédent (${d})`,
137    more: (n: number, path: string) => `…et ${n} de plus (${path})`,
138    doneTitle: (d: string) => `# Terminé le ${d}`,
139    session: (label: string) => `## Session ${label}`,
140    missedToast: (f: string) => `${f} n’a pas été mis à jour`,
141    missedStatus: (f: string) => `${f} non mis à jour`,
142  },
143  'de': {
144    now: '## Jetzt',
145    done: (d: string) => `## Erledigt (${d})`,
146    todoTitle: 'TODO',
147    pinTitle: 'Angepinnt',
148    emptyTodo: '(Noch keine TODOs)',
149    emptyPin: '(Noch nichts angepinnt)',
150    prevDay: (d: string) => `#### Vorheriger Tag (${d})`,
151    more: (n: number, path: string) => `…und ${n} weitere (${path})`,
152    doneTitle: (d: string) => `# Erledigt am ${d}`,
153    session: (label: string) => `## Sitzung ${label}`,
154    missedToast: (f: string) => `${f} wurde nicht aktualisiert`,
155    missedStatus: (f: string) => `${f} nicht aktualisiert`,
156  },
157  'pt': {
158    now: '## Agora',
159    done: (d: string) => `## Concluído (${d})`,
160    todoTitle: 'TODO',
161    pinTitle: 'Fixados',
162    emptyTodo: '(Nenhum TODO ainda)',
163    emptyPin: '(Nada fixado ainda)',
164    prevDay: (d: string) => `#### Dia anterior (${d})`,
165    more: (n: number, path: string) => `…e mais ${n} (${path})`,
166    doneTitle: (d: string) => `# Concluído em ${d}`,
167    session: (label: string) => `## Sessão ${label}`,
168    missedToast: (f: string) => `${f} não foi atualizado`,
169    missedStatus: (f: string) => `${f} não atualizado`,
170  },
171  'it': {
172    now: '## Ora',
173    done: (d: string) => `## Fatto (${d})`,
174    todoTitle: 'TODO',
175    pinTitle: 'Fissati',
176    emptyTodo: '(Nessun TODO per ora)',
177    emptyPin: '(Nulla di fissato per ora)',
178    prevDay: (d: string) => `#### Giorno precedente (${d})`,
179    more: (n: number, path: string) => `…e altri ${n} (${path})`,
180    doneTitle: (d: string) => `# Fatto il ${d}`,
181    session: (label: string) => `## Sessione ${label}`,
182    missedToast: (f: string) => `${f} non è stato aggiornato`,
183    missedStatus: (f: string) => `${f} non aggiornato`,
184  },
185  'ru': {
186    now: '## Сейчас',
187    done: (d: string) => `## Готово (${d})`,
188    todoTitle: 'TODO',
189    pinTitle: 'Закреплено',
190    emptyTodo: '(TODO пока нет)',
191    emptyPin: '(Ничего не закреплено)',
192    prevDay: (d: string) => `#### Предыдущий день (${d})`,
193    more: (n: number, path: string) => `…и ещё ${n} (${path})`,
194    doneTitle: (d: string) => `# Сделано за ${d}`,
195    session: (label: string) => `## Сессия ${label}`,
196    missedToast: (f: string) => `${f} не был обновлён`,
197    missedStatus: (f: string) => `${f} не обновлён`,
198  },
199}
200// Maps Claude Code's free-text `language` setting (English or native name, or a code) to a table key; anything else is English.
201// Traditional Chinese is tested before the general Chinese pattern.
202const LANG_PATTERNS: [Lang, RegExp][] = [
203  ['zh-Hant', /繁|traditional|^zh[-_ ]?(tw|hk|mo|hant)(?!\p{L})/iu],
204  ['zh-Hans', /^(中文|简|汉)|^(chinese|simplified|zh)(?!\p{L})/iu],
205  ['ja', /^(ja|japanese|日本語)/i],
206  ['ko', /^(ko|korean|한국어)(?!\p{L})/iu],
207  ['es', /^(es|spanish|español|espanol)(?!\p{L})/iu],
208  ['fr', /^(fr|french|français|francais)(?!\p{L})/iu],
209  ['de', /^(de|german|deutsch)(?!\p{L})/iu],
210  ['pt', /^(pt|portuguese|português|portugues)(?!\p{L})/iu],
211  ['it', /^(it|italian|italiano)(?!\p{L})/iu],
212  ['ru', /^(ru|russian|русский)(?!\p{L})/iu],
213]
214export const langFrom = (language: unknown): Lang => {
215  const s = String(language ?? '').trim()
216  return LANG_PATTERNS.find(([, re]) => re.test(s))?.[0] ?? 'en'
217}
218// Set once in session.start; English until then.
219let lang: Lang = 'en'
220let langRead = false
221
222// The done heading of any language in L, built from the table so a dated heading of the user's own
223// (e.g. "## Meeting notes (2026-10-01)") is never taken for it.
224const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
225const DONE_RE = new RegExp(`^(?:${[...new Set(Object.values(L).map(t => esc(t.done('\u0000')).replace('\u0000', '\\d{4}-\\d{2}-\\d{2}')))].join('|')})\\s*$`)
226const LEGACY_RE = /^## 今日やったこと\s*$/
227
228// Date changed: move the old done section (any language) out of the todo text. Pure; the caller does the I/O.
229export const rollover = (text: string, today: string, lang: Lang): { text: string; archived?: { date: string; body: string } } => {
230  const lines = text.split('\n')
231  const i = lines.findIndex(l => DONE_RE.test(l) || LEGACY_RE.test(l))
232  if (i < 0) return { text }
233  if (LEGACY_RE.test(lines[i])) {
234    lines[i] = L[lang].done(today)
235    return { text: lines.join('\n') }
236  }
237  const date = lines[i].match(/\d{4}-\d{2}-\d{2}/)![0]
238  if (date >= today) return { text }
239  let j = i + 1
240  while (j < lines.length && !lines[j].startsWith('## ')) j++
241  const body = lines.slice(i + 1, j).join('\n').trim()
242  const out = [...lines.slice(0, i), L[lang].done(today), '', ...lines.slice(j)].join('\n')
243  return body ? { text: out, archived: { date, body } } : { text: out }
244}
245
246// New content for <dir>/todo/done/<date>.md with this session's section appended.
247export const appendDone = (existing: string | undefined, date: string, sessionLabel: string, body: string, lang: Lang) => {
248  const head = existing ? existing.replace(/\s+$/, '') + '\n\n' : `${L[lang].doneTitle(date)}\n\n`
249  return `${head}${L[lang].session(sessionLabel)}\n\n${body}\n`
250}
251
252// True when today's done section already has a checked item; the previous day is shown only when it has none.
253export const hasDoneToday = (text: string, date: string, lang: Lang) => {
254  const lines = text.split('\n')
255  const i = lines.indexOf(L[lang].done(date))
256  if (i < 0) return false
257  for (const l of lines.slice(i + 1)) {
258    if (l.startsWith('## ')) break
259    if (l.startsWith('- [x]')) return true
260  }
261  return false
262}
263
264// Last 5 "- [x]" lines of a done file, for the TODO tab (display only).
265export const prevDayBlock = (date: string, fileText: string, path: string, lang: Lang) => {
266  const items = fileText.split('\n').filter(l => l.startsWith('- [x]'))
267  if (!items.length) return ''
268  const more = items.length - 5
269  const lines = items.slice(-5)
270  if (more > 0) lines.push(L[lang].more(more, path))
271  return `\n\n---\n\n${L[lang].prevDay(date)}\n\n${lines.join('\n')}`
272}
273
274// Display width of one code point: East Asian Wide/Fullwidth and emoji take 2 columns, the rest 1.
275// ponytail: range table, not full Unicode width data; combining marks and ambiguous-width characters count as 1.
276const isWide = (c: number) =>
277  (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0x303e) || (c >= 0x3041 && c <= 0x33ff) ||
278  (c >= 0x3400 && c <= 0x4dbf) || (c >= 0x4e00 && c <= 0x9fff) || (c >= 0xa000 && c <= 0xa4cf) ||
279  (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) ||
280  (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) ||
281  (c >= 0x20000 && c <= 0x3fffd)
282export const displayWidth = (s: string) => {
283  let w = 0
284  for (const ch of s) w += isWide(ch.codePointAt(0)!) ? 2 : 1
285  return w
286}
287const LIST_RE = /^(\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?)/
288const HEADING_RE = /^(#{1,6}\s+)/
289const MARGIN = 2
290
291// Cuts each over-long line to `columns` display columns and ends it with "…", so the Markdown element
292// never wraps it. Display only. Blank lines, fenced code and table rows are left alone.
293export const truncateForWidth = (markdown: string, columns: number): string => {
294  let fence = false
295  return markdown
296    .split('\n')
297    .map(line => {
298      const t = line.trimStart()
299      if (/^(```|~~~)/.test(t)) {
300        fence = !fence
301        return line
302      }
303      if (fence || !t || t.startsWith('|')) return line
304      const list = line.match(LIST_RE)?.[1]
305      const heading = list ? undefined : line.match(HEADING_RE)?.[1]
306      const head = list ?? heading ?? ''
307      const rest = line.slice(head.length)
308      // A heading's # marks are not drawn; a list marker is drawn about as wide as its source.
309      const budget = columns - MARGIN - (heading ? 0 : displayWidth(head))
310      if (budget < 4 || displayWidth(rest) <= budget) return line
311      let cut = ''
312      let w = 0
313      for (const ch of rest) {
314        const cw = isWide(ch.codePointAt(0)!) ? 2 : 1
315        if (w + cw > budget - 1) break
316        cut += ch
317        w += cw
318      }
319      // No half-open link or code span: either would swallow what follows.
320      const lb = cut.lastIndexOf('[')
321      if (lb >= 0 && !/\]\([^)]*\)/.test(cut.slice(lb))) cut = cut.slice(0, lb)
322      if ((cut.match(/`/g) ?? []).length % 2) cut = cut.slice(0, cut.lastIndexOf('`'))
323      return head + cut + '…'
324    })
325    .join('\n')
326}
327
328const NOW_PANE = 'todo-pane'
329const PIN_PANE = 'pin-board'
330
331// The engine's scan needs each atom named directly where read/update use it, so the two boards are spelled out.
332const nowText = atom({ plugin: 'todo-pane', key: 'text' } as const, '')
333const pinText = atom({ plugin: 'todo-pane', key: 'pin' } as const, '')
334// true: long TODO lines wrap; false (default): they are cut with "…". Saved with $.store so it outlives the session.
335const wrapOn = atom({ plugin: 'todo-pane', key: 'wrap' } as const, false)
336
337// Sent to the model every turn, so the board stays current without relying on memory.
338const rule = (f: string, lang: Lang) => [
339  `${f} is the TODO list shown in the right-hand pane.`,
340  '- Write every item as a checkbox: `- [ ] ` for open, `- [x] ` for done. Never use a plain `- ` bullet.',
341  `- Before starting any work that changes files or Notion, first write it under "${L[lang].now}" in ${f}.`,
342  `- When the work is done, check it and move it under "${L[lang].done(today())}"; keep remaining items in their sections.`,
343  `- When the topic changes, update the "${L[lang].now}" section to match.`,
344  '- Write the items in the language the user is writing in.',
345].join('\n')
346const ask = (f: string, off: string, lang: Lang) =>
347  `The TODO file ${f} (the TODO list shown in the right-hand pane) does not exist. At the very start of this turn, before anything else, ask the user with AskUserQuestion whether to create a TODO list.\n- Yes: create ${f} with the headings "${L[lang].now}" and "${L[lang].done(today())}", and write the current task under "${L[lang].now}" as \`- [ ] \`.\n- No: create an empty file at ${off} (do not ask again this session).`
348// Sent every turn too, even after a "no" to the todo list: the pin board is independent of it.
349const pinRule = (f: string, lang: Lang) =>
350  `When asked to "pin" something (e.g. "pin that", 「ピン留めして」), write the target (the explanation, comparison table, summary, etc. you just gave) as Markdown to ${f}, creating it if missing. This is the "${L[lang].pinTitle}" tab of this plugin's side pane, not claude.ai Artifact pinning. The user can also run the /todo-pane:pin skill. Only write it when asked; do not update it as work progresses.`
351const emptyBoard = (lang: Lang) => `${L[lang].now}\n\n${L[lang].done(today())}\n`
352const missedText = (f: string) =>
353  `In the previous turn files or Notion were changed, but ${f} was not updated. At the start of this turn, update ${f} to the current state.`
354
355// Tools that change something the person would expect the board to mention.
356export const isWrite = (tool: string, path: string, now: string, off: string) =>
357  (['Write', 'Edit', 'NotebookEdit'].includes(tool) && !path.endsWith(now) && !path.endsWith(off)) ||
358  /notion.*(update|create|patch|post|delete|move|duplicate)/i.test(tool)
359
360// ponytail: per-session flags in module scope; a reload resets them, which only skips one check
361let before: string | undefined
362let wrote = false
363let missed = false
364
365let dir = '.claude'
366let sessionId = ''
367let base = ''
368let p = pathsFor(dir, '')
369
370// Latest custom-title from the transcript's tail (the file can exceed fs.read's 4 MiB, so tail it).
371async function titleOf($: any, id: string) {
372  const script = 'f=$(ls "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/"$1".jsonl 2>/dev/null | head -1); [ -n "$f" ] && tail -n 500 "$f"'
373  const r = await $.process.run(['sh', '-c', script, 'sh', id]).catch(() => undefined)
374  return r ? lastTitle(String(r.stdout)) : undefined
375}
376
377// Recompute <base>; if it changed, carry the old files (todo, pin and off) to the new names.
378async function sync($: any) {
379  // Claude Code's `language` setting picks the fixed strings (settings can fail to read: English).
380  // Read here, not only in session.start, so a hot reload that skips session.start still gets it.
381  if (!langRead) {
382    lang = langFrom((await $.settings.read().catch(() => undefined))?.language)
383    langRead = true
384  }
385  if (!sessionId) sessionId = await $.session.id()
386  const next = baseName(await titleOf($, sessionId), sessionId)
387  if (next === base) return
388  const old = base ? pathsFor(dir, base) : undefined
389  base = next
390  p = pathsFor(dir, base)
391  if (!old) return
392  for (const k of ['todo', 'pin', 'off'] as const) {
393    if (!(await $.fs.exists(old[k])) || (await $.fs.exists(p[k]))) continue
394    await $.fs.write(p[k], String(await $.fs.read(old[k])))
395    await $.process.run(['rm', '-f', old[k]])
396  }
397}
398
399// Roll the done section over when the date changed. Runs outside Claude's tool calls and before turn.start
400// snapshots `before`, so it never reads as a missed update.
401// The 3s refresh and prompt.submit can both get here; one run at a time, or a section could be archived twice.
402let archiving: Promise<void> | undefined
403function archiveIfNeeded($: any) {
404  archiving ??= rollOver($).finally(() => { archiving = undefined })
405  return archiving
406}
407async function rollOver($: any) {
408  if (!(await $.fs.exists(p.todo))) return
409  const text = String(await $.fs.read(p.todo))
410  const r = rollover(text, today(), lang)
411  if (r.text !== text) await $.fs.write(p.todo, r.text)
412  if (!r.archived) return
413  const file = `${dir}/todo/done/${r.archived.date}.md`
414  await $.process.run(['mkdir', '-p', `${dir}/todo/done`])
415  const existing = await $.fs.read(file).then(String).catch(() => undefined)
416  await $.fs.write(file, appendDone(existing, r.archived.date, base, r.archived.body, lang))
417}
418
419// Most recent earlier day's finished items, shown under the TODO tab only.
420async function prevDay($: any) {
421  const names: { name: string }[] = await $.fs.list(`${dir}/todo/done`).catch(() => [])
422  const t = today()
423  const last = names.map(n => n.name).filter(n => /^\d{4}-\d{2}-\d{2}\.md$/.test(n) && n.slice(0, 10) < t).sort().pop()
424  if (!last) return ''
425  const path = `${dir}/todo/done/${last}`
426  return prevDayBlock(last.slice(0, 10), String(await $.fs.read(path).catch(() => '')), path, lang)
427}
428
429// What a pane command does: not open -> open it in front; open but behind another tab -> bring it forward; in front -> close.
430export const paneAction = ({ isOpen, isFront }: { isOpen: boolean; isFront: boolean }): 'open' | 'front' | 'close' =>
431  !isOpen ? 'open' : isFront ? 'close' : 'front'
432// $.ui.open with focus raises the tab (and retitles an open one), so open and front are the same call.
433const openPane = ($: any, id: string, title: string) => $.ui.open({ id, title, focus: true })
434// The engine's record of this plugin's panes; isShown marks the one tab in front. An unplaced pane counts as not open.
435async function stateOf($: any, id: string) {
436  const pane = ((await $.ui.panes().catch(() => [])) as { id: string; isShown: boolean; isPlaced: boolean }[]).find(x => x.id === id)
437  return { isOpen: !!pane?.isPlaced, isFront: !!pane?.isShown }
438}
439
440export const register: Register = (on, options) => {
441  dir = dirFrom(options)
442  p = pathsFor(dir, base)
443
444  on('session.start', async ($, e, next) => {
445    await sync($)
446    const savedWrap = (await $.store.get('wrap').catch(() => undefined)) === true
447    const refresh = async () => {
448      await sync($).catch(() => {})
449      await archiveIfNeeded($).catch(() => {})
450      const file = String(await $.fs.read(p.todo).catch(() => '')).slice(0, 9000)
451      const now = file ? file + (hasDoneToday(file, today(), lang) ? '' : await prevDay($)) : file
452      const pin = String(await $.fs.read(p.pin).catch(() => '')).slice(0, 9000)
453      await update($, nowText, prev => (prev === now ? prev : now))
454      await update($, pinText, prev => (prev === pin ? prev : pin))
455      return pin
456    }
457    await update($, wrapOn, () => savedWrap)
458    const pin = await refresh()
459    // ponytail: polls every 3s; a file watch would be nicer if the API grows one
460    $.clock.every(3000, () => void refresh())
461    void openPane($, NOW_PANE, L[lang].todoTitle)
462    // The pin board opens at start only when this session's file exists; /todo-pane-pinboard opens it later.
463    if (pin) void openPane($, PIN_PANE, L[lang].pinTitle)
464    // Last, and each guarded: a taken name must not stop the panes or the timer above.
465    for (const [name, description] of [['todo-pane', 'Open, bring forward, or close the TODO pane'], ['todo-pane-pinboard', 'Open, bring forward, or close the pinboard pane'], ['todo-pane-wrap', 'Toggle wrapping long lines in the TODO pane']])
466      try { await $.command.register({ name, description }) } catch (err) { $.ui.log(`command ${name} not registered: ${err}`, { to: 'debug' }) }
467
468    return next(e)
469  })
470
471  // Attached to every prompt as context the model reads beside it (never shown, text untouched),
472  // so the instruction reflects the board's state at that turn; prompt.compose is frozen after the first turn.
473  on('prompt.submit', async ($, e, next) => {
474    await sync($).catch(() => {})
475    await archiveIfNeeded($).catch(() => {})
476    const pin = pinRule(p.pin, lang)
477    if (await $.fs.exists(p.off)) return next({ ...e, context: [...(e.context ?? []), pin] })
478    const text = (await $.fs.exists(p.todo)) ? (missed ? `${rule(p.todo, lang)}\n${missedText(p.todo)}` : rule(p.todo, lang)) : ask(p.todo, p.off, lang)
479    return next({ ...e, context: [...(e.context ?? []), text, pin] })
480  })
481
482  on('turn.start', async ($, e, next) => {
483    before = await $.fs.read(p.todo).then(String).catch(() => undefined)
484    wrote = false
485    return next(e)
486  })
487
488  on('tool.call', async ($, e, next) => {
489    if (!e.agentId) {
490      const path = 'file_path' in e ? String(e.file_path) : ''
491      if (isWrite(e.tool, path, p.todo, p.off)) wrote = true
492    }
493    return next(e)
494  })
495
496  on('turn.complete', async ($, e, next) => {
497    if (!e.agentId && !e.isAborted && before !== undefined) {
498      const after = await $.fs.read(p.todo).then(String).catch(() => undefined)
499      missed = wrote && after === before
500      if (missed) $.ui.toast(L[lang].missedToast(p.todo))
501      $.ui.status(missed ? L[lang].missedStatus(p.todo) : undefined)
502    }
503    return next(e)
504  })
505
506  // Both commands: open in front / bring forward / close (see paneAction). /todo-pane opening is an explicit yes: create the board if missing and drop an earlier "no" (.off).
507  on('command.run', { command: 'todo-pane' }, async $ => {
508    const act = paneAction(await stateOf($, NOW_PANE))
509    if (act === 'close') {
510      await $.ui.close({ id: NOW_PANE })
511      return { text: 'Closed.' }
512    }
513    if (act === 'front') {
514      await openPane($, NOW_PANE, L[lang].todoTitle)
515      return { text: 'Brought forward.' }
516    }
517    // Pick up a name set by -n / /rename first, or the file is created under the bare id and renamed 3s later.
518    await sync($).catch(() => {})
519    let created = false
520    if (!(await $.fs.exists(p.todo))) {
521      // $.fs has no mkdir or remove, so those go through the shell tools.
522      await $.process.run(['mkdir', '-p', `${dir}/todo`]).catch(() => {})
523      await $.fs.write(p.todo, emptyBoard(lang))
524      created = true
525    }
526    if (await $.fs.exists(p.off)) await $.process.run(['rm', '-f', p.off]).catch(() => {})
527    if (created) await update($, nowText, () => emptyBoard(lang))
528    await openPane($, NOW_PANE, L[lang].todoTitle)
529    return { text: created ? `Created ${p.todo} and opened.` : 'Opened.' }
530  })
531
532  on('command.run', { command: 'todo-pane-pinboard' }, async $ => {
533    const act = paneAction(await stateOf($, PIN_PANE))
534    if (act === 'close') {
535      await $.ui.close({ id: PIN_PANE })
536      return { text: 'Closed.' }
537    }
538    await openPane($, PIN_PANE, L[lang].pinTitle)
539    return { text: act === 'front' ? 'Brought forward.' : 'Opened.' }
540  })
541
542  on('command.run', { command: 'todo-pane-wrap' }, async $ => {
543    const v = !(await read($, wrapOn))
544    await update($, wrapOn, () => v)
545    await $.store.set('wrap', v).catch(() => {})
546    return { text: v ? 'Wrap on.' : 'Wrap off (truncate).' }
547  })
548
549  // The pin skill: tell it the concrete file and show the pins pane.
550  on('skill.prompt', async ($, e, next) => {
551    const r = await next(e)
552    if (e.skill !== 'todo-pane:pin' || !r || !('text' in r)) return r
553    await sync($).catch(() => {})
554    if (!(await stateOf($, PIN_PANE)).isOpen) await openPane($, PIN_PANE, L[lang].pinTitle).catch(() => {})
555    return { text: `${r.text}\n\nPin file for this session: ${p.pin} (create it if missing).` }
556  })
557
558  on('ui.render', { component: 'Pane', requestId: NOW_PANE }, async ($, e) => {
559    const { Box, Markdown } = $.ui.resolve(e)
560    const body = await read($, nowText)
561    // Width of this draw; the engine redraws when it changes. Without one, nothing is cut.
562    const cols = Number((e.props as any)?.bodyColumns)
563    const text = body || L[lang].emptyTodo
564    const wrap = await read($, wrapOn)
565    return (
566      <Box flexDirection="column">
567        <Markdown text={wrap || !(cols > 0) ? text : truncateForWidth(text, cols)} />
568      </Box>
569    )
570  })
571
572  on('ui.render', { component: 'Pane', requestId: PIN_PANE }, async ($, e) => {
573    const { Box, Markdown } = $.ui.resolve(e)
574    const body = await read($, pinText)
575    return (
576      <Box flexDirection="column">
577        <Markdown text={body || L[lang].emptyPin} />
578      </Box>
579    )
580  })
581}
582
types/index.d.ts 8 lines
1export type NowText = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'todo-pane': { text: NowText; pin: NowText; wrap: boolean }
6  }
7}
8