SLOPSHOPPER

justchat

A /justchat mode where Claude supresses actions like writing code things through instead of writing code in favor of just chatting until told otherwise

bandspinnerguardcommandtoastprompt
v1.0.0Apache-2.0updated 2026-10-18orvn/justchat-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · justchat
› fix the failing auth test and add an audit log call ⏺ 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 › /justchat ⎿ justchat: Chat mode on: Claude will just talk with you, or at most examine files, and not write any code, leave with /ju ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 💬 JUST CHAT MODE Claude is chatting [ Back to code ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 💬 JUST CHAT MODE Claude is chatting [ Back to code ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Claude Code mod Conventional Commits

/justchat

A Claude Code mod that adds the slash command /justchat. Running the command puts Claude in chat mode: it chats with you until instructed otherwise, with full context and ability to perform file reads and access web resources.

Claude Code is heavily biased toward multimodal action: editing files, running commands, drafting plans, etc. Sometimes it's important to discuss topics without the (often sudden) diversion toward work implementation. The native /btw slash typically only lasts one response, and isn't able to see conversational context or access files.

Install

This is implemented using official Claude Mod guidelines. As this is a recent (Oct 2026) features, it requires Claude Code version <= 2.1.287.

During development, clone this repo and install as follows. While in a Claude code terminal session:

/plugin install justchat --marketplace ~/justchat-claude-mod

Answer y to add the marketplace, then pick a scope

Usage

CommandDoes
/justchatTurns chat mode on, or off if it's already on
/justchat <message>Turns chat mode on and sends the message
/justchat onTurns chat mode on
/justchat offTurns chat mode off

While chat mode is on:

  • A 💬 band above the prompt shows the mode, with a Back to code button
  • After each reply, a line under the answer reminds you how to continue or leave, and the band offers Keep chatting
  • The band's buttons answer k and c once the band has focus (ctrl+x tab or a click)

How it works

/justchat supresses non-chat behavior in two ways:

  1. Instruction: Every prompt carries a hidden instruction: discuss, explain, compare, give opinions. No file edits, no state-changing commands, no implementation plans. Short snippets are fine when they illustrate a point
  1. Blocking: Instruction alone isn't a guarantee, so tools are allowlisted while chat mode is on:
  2. Allowed: Read, Grep, Glob, LSP, web search and fetch, tool search, MCP resource reads, task and agent lists, AskUserQuestion, Agent and Skill (their own calls are checked too)
  3. Bash: only read-only commands, such as ls, cat, grep, find without -delete or -exec, sed without -i, and read-only git. Redirects, command substitution and unknown commands are refused
  4. Everything else is refused, including Edit, Write, Monitor, Workflow and every MCP tool (MCP tools give no read-only signal before they run, so read-only ones are blocked too)

When a call is refused, Claude is told why and asked to discuss instead or to suggest leaving chat mode

Chat mode persists through changing session IDs, which can happen in a single chat.

Development

Run it from a clone for one session. Run ./scripts/setup.sh once after cloning.

claude --plugin-dir .

Saving a file reloads the mod in place, this can be checked with:

claude plugin validate .
claude plugin test .

Structure

  • hooks/register.tsx- Hooks, incl. command, tool guard, reply line, band, etc.
  • hooks/copy.ts: Prompt instruction
  • hooks/readonly.ts: Allowlist and the read-only shell check
  • types/index.d.ts: Session state contract
  • tests/: Tests (run by claude plugin test)

Conventional Commits

Commits must use a conventional prefix, .githooks/ rejects anything else. Allowed types: feat, fix, hotfix, refactor, style, chore, cleanup, perf, test, docs, content

changelog.md is generated with git-cliff:

git cliff -o changelog.md
Source 4 files
hooks/register.tsx 223 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { copy } from './copy'
5import { isAllowedCall } from './readonly'
6
7const isOn = atom({ plugin: 'justchat', key: 'isOn' } as const, false)
8const isAsking = atom({ plugin: 'justchat', key: 'isAsking' } as const, false)
9const hasJustLeft = atom({ plugin: 'justchat', key: 'hasJustLeft' } as const, false)
10const persistedSessionId = atom({ plugin: 'justchat', key: 'persistedSessionId' } as const, null)
11
12// Newest marker in the transcript decides the mode
13const isOnPersisted = async ($: EngineInterface) => {
14  const messages = await $.session.messages()
15
16  for (let i = messages.length - 1; i >= 0; i--) {
17    const text = messages[i]?.text ?? ''
18    const on = text.lastIndexOf(copy.persistTagOn)
19    const off = text.lastIndexOf(copy.persistTagOff)
20
21    if (on !== -1 || off !== -1) {
22      return on > off
23    }
24  }
25
26  return false
27}
28
29// Interrupted session clears into a new id, rebuilds from the conversation
30const persistSession = async ($: EngineInterface) => {
31  try {
32    const id = await $.session.id()
33
34    if ((await read($, persistedSessionId)) === id) {
35      return
36    }
37
38    const restored = await isOnPersisted($)
39    await update($, persistedSessionId, () => id)
40    await update($, isOn, () => restored)
41    await update($, isAsking, () => false)
42    await update($, hasJustLeft, () => false)
43  } catch {
44    // Keep the current state rather than drop the hook
45  }
46}
47
48const persistMode = async ($: EngineInterface, text: string) => {
49  try {
50    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
51  } catch {
52    // Mid-turn appends can be refused, the next prompt's context carries the tag anyway
53  }
54}
55
56const enter = async ($: EngineInterface) => {
57  await update($, isOn, () => true)
58  await update($, isAsking, () => false)
59  await update($, hasJustLeft, () => false)
60  await persistMode($, copy.persistMarkOn)
61}
62
63const leave = async ($: EngineInterface) => {
64  await update($, isOn, () => false)
65  await update($, isAsking, () => false)
66  await update($, hasJustLeft, () => true)
67  await persistMode($, copy.persistMarkOff)
68}
69
70export const register: Register = on => {
71  on('session.start', async ($, e, next) => {
72    await $.command.register({
73      name: 'justchat',
74      description: 'Talk things through without Claude writing code',
75      argumentHint: '[off | message]',
76    })
77    await persistSession($)
78
79    return next(e)
80  })
81
82  on('command.run', { command: 'justchat' }, async ($, e) => {
83    await persistSession($)
84    const arg = e.args.trim()
85    const wasOn = await read($, isOn)
86
87    if (arg === 'off' || (arg === '' && wasOn)) {
88      if (!wasOn) {
89        return { text: copy.alreadyOff }
90      }
91      await leave($)
92
93      return { text: copy.off }
94    }
95
96    if (arg === 'on' && wasOn) {
97      return { text: copy.alreadyOn }
98    }
99
100    await enter($)
101
102    if (arg !== '' && arg !== 'on') {
103      void $.prompt.submit({ text: arg, asUser: true })
104    }
105
106    return { text: copy.on }
107  })
108
109  on('prompt.submit', async ($, e, next) => {
110    await persistSession($)
111
112    if (await read($, hasJustLeft)) {
113      await update($, hasJustLeft, () => false)
114
115      return next({ ...e, context: [...(e.context ?? []), copy.ended] })
116    }
117
118    if (!(await read($, isOn))) {
119      return next(e)
120    }
121
122    await update($, isAsking, () => false)
123
124    return next({ ...e, context: [...(e.context ?? []), copy.instruction] })
125  })
126
127  on('tool.call', async ($, e, next) => {
128    await persistSession($)
129
130    if (!(await read($, isOn))) {
131      return next(e)
132    }
133    if (e.tool === 'Bash') {
134      return isAllowedCall(e.tool, e.command) ? next(e) : { deny: copy.denyBash }
135    }
136
137    return isAllowedCall(e.tool) ? next(e) : { deny: copy.denyTool(e.tool) }
138  }).catch(($, e, next) =>
139    next.called ? next(e) : { deny: 'justchat: its write guard failed, so the call is blocked.' },
140  )
141
142  on('turn.complete', async ($, e, next) => {
143    await persistSession($)
144    const done = await next(e)
145
146    if (e.agentId !== undefined || e.reason !== 'answer' || !(await read($, isOn))) {
147      return done
148    }
149
150    await update($, isAsking, () => true)
151
152    return { ...done, text: copy.afterReply }
153  })
154
155  on('session.compact', async ($, e, next) => {
156    const done = await next(e)
157
158    if (await read($, isOn)) {
159      await persistMode($, copy.persistMarkOn)
160    }
161
162    return done
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    await persistSession($)
167
168    if (e.props.hasSurvey || !(await read($, isOn))) {
169      return next(e)
170    }
171
172    const { Box, Button, Text } = $.ui.resolve(e)
173    const asking = await read($, isAsking)
174
175    return (
176      <Box
177        borderStyle="round"
178        borderColor="suggestion"
179        paddingX={1}
180        justifyContent="space-between"
181        flexGrow={1}
182      >
183        <Box gap={1} flexShrink={1} overflow="hidden">
184          <Text bold color="inverseText" backgroundColor="suggestion" wrap="truncate">
185            {copy.band}
186          </Text>
187          <Text color="suggestion" wrap="truncate-end">
188            {copy.bandHint}
189          </Text>
190        </Box>
191        <Box gap={1} flexShrink={0}>
192          {asking && (
193            <Button
194              key="keep"
195              label={copy.keep}
196              hotkey="k"
197              onPress={() => update($, isAsking, () => false)}
198            />
199          )}
200          <Button
201            key="back"
202            label={copy.back}
203            hotkey="c"
204            onPress={async () => {
205              await leave($)
206              $.ui.toast(copy.off)
207            }}
208          />
209        </Box>
210      </Box>
211    )
212  })
213  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
214    await persistSession($)
215
216    if (!(await read($, isOn))) {
217      return next(e)
218    }
219
220    return next({ ...e, props: { ...e.props, modes: [copy.footerMode, ...e.props.modes] } })
221  })
222}
223
hooks/copy.ts 34 lines
1const PERSIST_TAG_ON = '[justchat:on]'
2const PERSIST_TAG_OFF = '[justchat:off]'
3
4export const copy = {
5  persistTagOn: PERSIST_TAG_ON,
6  persistTagOff: PERSIST_TAG_OFF,
7  persistMarkOn: `${PERSIST_TAG_ON} The user turned on chat mode.`,
8  persistMarkOff: `${PERSIST_TAG_OFF} The user turned off chat mode.`,
9  instruction: [
10    PERSIST_TAG_ON,
11    'The user turned on chat mode with /justchat. They want a conversation, not code.',
12    'Discuss, explain, compare, ask questions and give opinions. Keep answers conversational.',
13    'Do not write, edit or create files, do not run commands that change anything, unless the user explicitly asks to exit just chat mode.',
14    'Reading files and running read-only commands to inform the discussion is acceptable.',
15    'Short code snippets are fine to display only when they illustrate a point, but not to write to a file.',
16    'If the user asks you to make a change, tell them to leave chat mode first with /justchat off or the "Back to code" button.',
17  ].join(' '),
18  ended: `${PERSIST_TAG_OFF} The user left chat mode. Return to normal unrestricted behavior: writing code, editing files and running commands are once again permitted.`,
19  afterReply: '💬 Keep typing to chat, or leave with /justchat off',
20  band: ' 💬 JUST CHAT MODE ',
21  bandHint: "Claude is chatting",
22  footerMode: '💬 just chat mode',
23  keep: 'Just keep chatting',
24  back: 'Back to code',
25  on: 'Chat mode on: Claude will just talk with you, or at most examine files, and not write any code, leave with /justchat off',
26  off: 'Chat mode off: back to writing code',
27  alreadyOff: 'Just chat mode is already off',
28  alreadyOn: 'Just chat mode is already on',
29  denyTool: (tool: string) =>
30    `justchat: chat mode is on, so only reading tools run and ${tool} is blocked. Discuss instead, or ask the user to leave chat mode with /justchat off.`,
31  denyBash:
32    'justchat: chat mode is on, so only read-only shell commands run. Discuss instead, or ask the user to leave chat mode with /justchat off.',
33}
34
hooks/readonly.ts 65 lines
1const SAFE = new Set([
2  'ls', 'cat', 'head', 'tail', 'grep', 'rg', 'ag', 'wc', 'file', 'stat', 'pwd',
3  'which', 'type', 'echo', 'printf', 'tree', 'du', 'df', 'diff', 'sort', 'uniq',
4  'cut', 'tr', 'jq', 'basename', 'dirname', 'realpath', 'date', 'env', 'uname',
5  'whoami', 'true', 'column', 'nl', 'less', 'more', 'fd', 'find', 'sed', 'git',
6])
7
8const SAFE_GIT = new Set([
9  'status', 'log', 'diff', 'show', 'blame', 'grep', 'ls-files', 'rev-parse',
10  'describe', 'shortlog', 'reflog', 'branch', 'tag', 'remote', 'config',
11])
12
13const isSafeSegment = (segment: string) => {
14  const words = segment.trim().split(/\s+/).filter(Boolean)
15  const [cmd, sub] = words
16
17  if (cmd === undefined) {
18    return true
19  }
20  if (!SAFE.has(cmd)) {
21    return false
22  }
23  if (cmd === 'find') {
24    return !words.some(w => /^-(delete|exec|execdir|ok|okdir|fprint|fls)/.test(w))
25  }
26  if (cmd === 'sed') {
27    return !words.some(w => /^-[a-zA-Z]*i/.test(w) || w === '--in-place')
28  }
29  if (cmd === 'git') {
30    if (sub === undefined || !SAFE_GIT.has(sub)) {
31      return false
32    }
33    if (sub === 'branch' || sub === 'tag' || sub === 'remote') {
34      return words.slice(2).every(w => /^-(v|vv|a|r|l|-list|-all|-verbose)$/.test(w))
35    }
36    if (sub === 'config') {
37      return words.slice(2).some(w => /^(--get|--get-all|--list|-l)$/.test(w))
38    }
39  }
40
41  return true
42}
43
44// Any redirection, substitution or an unknown command is not read-only
45export const isReadOnlyCommand = (command: string) => {
46  const stripped = command.replace(/\d?>\s*\/dev\/null/g, '').replace(/2>&1/g, '')
47
48  if (/[>`]|\$\(|<\(/.test(stripped)) {
49    return false
50  }
51
52  return stripped.split(/&&|\|\||[|;\n]/).every(isSafeSegment)
53}
54
55// Tools that only read or chat (anything not listed is refused)
56export const READ_TOOLS = new Set([
57  'Read', 'Grep', 'Glob', 'LSP', 'WebSearch', 'WebFetch', 'ToolSearch',
58  'AskUserQuestion', 'Agent', 'Skill', 'ListMcpResourcesTool',
59  'ReadMcpResourceTool', 'ReadMcpResourceDirTool', 'TaskGet', 'TaskList',
60  'ListAgents', 'ListSkills', 'ListPlugins', 'SearchSkills',
61])
62
63export const isAllowedCall = (tool: string, command?: string) =>
64  tool === 'Bash' ? command !== undefined && isReadOnlyCommand(command) : READ_TOOLS.has(tool)
65
types/index.d.ts 8 lines
1export type ChatFlag = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    justchat: { isOn: ChatFlag; isAsking: ChatFlag; hasJustLeft: ChatFlag; persistedSessionId: string | null }
6  }
7}
8