Add PLAN/BUILD modes (opencode-style), Shift-Tab toggle

ober

bf3d37385d6f5518ecc66e012390e6b3b5510ad7

diff --git a/lib/jcode/core/agent.sls b/lib/jcode/core/agent.sls
index 47ab5a3..461acd7 100644
--- a/lib/jcode/core/agent.sls
+++ b/lib/jcode/core/agent.sls
@@ -19,14 +19,35 @@
   (def current-model-override (make-parameter #f))
   (def (system-prompt)
        (format
-         "You are an expert AI coding assistant. You help users with software development tasks.\nWorking directory: ~a\n\nYou have access to these tools:\n- read, write, edit, multi-edit: Read and modify files\n- glob, grep, ls: Search and list files\n- bash: Execute shell commands\n- fetch: HTTP requests\n- batch: Run multiple tool calls in parallel\n- git_status, git_diff, git_log, git_show, git_commit: Git operations\n\nWhen the user asks you to do something:\n1. Think about what tools you need\n2. Use tools to gather information or make changes\n3. Report back with results\n\nBe concise and helpful. When editing files, make minimal targeted changes.\nPrefer using the edit tool over write for modifying existing files."
-         (current-directory)))
+         "You are an expert AI coding assistant. You help users with software development tasks.\nWorking directory: ~a\nCurrent mode: ~a\n\nYou have access to these tools:\n- read, write, edit, multi-edit: Read and modify files\n- glob, grep, ls: Search and list files\n- bash: Execute shell commands\n- fetch: HTTP requests\n- batch: Run multiple tool calls in parallel\n- git_status, git_diff, git_log, git_show, git_commit: Git operations\n\n~a\n\nWhen the user asks you to do something:\n1. Think about what tools you need\n2. Use tools to gather information or make changes\n3. Report back with results\n\nBe concise and helpful. When editing files, make minimal targeted changes.\nPrefer using the edit tool over write for modifying existing files."
+         (current-directory)
+         (mode-label (current-mode))
+         (mode-instructions (current-mode))))
+  (def (mode-label m)
+       (case m
+         [(plan) "PLAN (read-only)"]
+         [(build) "BUILD (read+write)"]
+         [else (format "~a" m)]))
+  (def (mode-instructions m)
+       (case m
+         [(plan)
+          "You are in PLAN mode: write/edit/bash/git_commit are disabled. Investigate the codebase with read tools (read, ls, glob, grep, git_status, git_diff, git_log, git_show, fetch) and produce a plan describing what you would change. Do NOT attempt write operations — they will be rejected. Tell the user to switch to BUILD mode (/build) when they want you to apply the plan."]
+         [else
+          "You are in BUILD mode: full read+write access is available."]))
   (def current-stream-cb (make-parameter #f))
   (def current-tool-cb (make-parameter #f))
   (def current-usage-cb (make-parameter #f))
   (def *max-tool-rounds* 8)
   (def *prune-protect-chars* 16000)
   (def *tool-result-stub* "[Old tool result cleared]")
+  (def (refresh-system-prompt messages)
+       "Replace the leading system message (if any) with a fresh one reflecting\n   the current mode. Returns a NEW list — does not mutate. If there's no\n   leading system message, prepends one."
+       (let ([fresh (make-system-message (system-prompt))])
+         (cond
+           [(null? messages) (list fresh)]
+           [(equal? (message-role (car messages)) "system")
+            (cons fresh (cdr messages))]
+           [else (cons fresh messages)])))
   (def (trim-messages messages)
        "Prune old tool results: walk backwards, protect recent ones, stub the rest."
        (let* ([reversed (reverse messages)]
@@ -95,7 +116,7 @@
   (def (agent-loop session-id messages round)
        (let* ([provider (get-current-provider)]
               [tools (get-tool-schemas)]
-              [msgs (trim-messages messages)]
+              [msgs (trim-messages (refresh-system-prompt messages))]
               [response (provider-chat provider msgs tools)])
          (log-debug
            logger
@@ -129,7 +150,7 @@
   (def (agent-loop-stream session-id messages round)
        (let* ([provider (get-current-provider)]
               [tools (get-tool-schemas)]
-              [msgs (trim-messages messages)])
+              [msgs (trim-messages (refresh-system-prompt messages))])
          (let-values ([(content tool-calls usage)
                        (provider-stream-chat
                          provider
diff --git a/lib/jcode/tool/registry.sls b/lib/jcode/tool/registry.sls
index 65f715f..8a294fe 100644
--- a/lib/jcode/tool/registry.sls
+++ b/lib/jcode/tool/registry.sls
@@ -4,7 +4,9 @@
 
 (library (jcode tool registry)
   (export register-tool! register-internal-tool! tool-execute
-    get-tool-schemas list-tools tool->openai-schema)
+    get-tool-schemas list-tools tool->openai-schema current-mode
+    mode-allows-tool? mode-blocked-message write-tool-name?
+    register-write-tools!)
   (import
     (except (chezscheme) make-hash-table hash-table? iota \x31;+ \x31;-
       getenv path-extension path-absolute? thread? make-mutex
@@ -13,6 +15,24 @@
     (jerboa runtime))
   (def logger (make-logger "tools"))
   (def *tools* (make-hash-table))
+  (def current-mode (make-parameter 'build))
+  (def *write-tools*
+       '("write" "edit" "multi-edit" "patch" "bash" "git_commit"))
+  (def (write-tool-name? name)
+       (and (member name *write-tools*) #t))
+  (def (mode-allows-tool? name)
+       (or (eq? (current-mode) 'build)
+           (not (write-tool-name? name))))
+  (def (mode-blocked-message name)
+       (format
+         "Tool '~a' is blocked in PLAN mode (read-only). Switch to BUILD mode with /build to run write tools."
+         name))
+  (def (register-write-tools! . names)
+       (for-each
+         (lambda (n)
+           (unless (member n *write-tools*)
+             (set! *write-tools* (cons n *write-tools*))))
+         names))
   (def (register-tool! name description schema handler)
        (hash-put!
          *tools*
@@ -50,22 +70,36 @@
                  (- (string-length str) keep)
                  (string-length str))))))
   (def (tool-execute name args)
-       (log-info logger "execute" `((name . ,name)))
-       (let ([tool (hash-get *tools* name)])
-         (if tool
-             (let ([result (try ((hash-ref tool "handler") args)
-                                (catch
-                                  (e)
-                                  (format
-                                    "Error executing ~a: ~a"
-                                    name
-                                    (err->string e))))])
-               (truncate-result result))
-             (format "Unknown tool: ~a" name))))
+       (log-info
+         logger
+         "execute"
+         `((name . ,name) (mode . ,(current-mode))))
+       (cond
+         [(not (mode-allows-tool? name))
+          (log-warn
+            logger
+            "blocked-by-mode"
+            `((name . ,name) (mode . ,(current-mode))))
+          (mode-blocked-message name)]
+         [else
+          (let ([tool (hash-get *tools* name)])
+            (if tool
+                (let ([result (try ((hash-ref tool "handler") args)
+                                   (catch
+                                     (e)
+                                     (format
+                                       "Error executing ~a: ~a"
+                                       name
+                                       (err->string e))))])
+                  (truncate-result result))
+                (format "Unknown tool: ~a" name)))]))
   (def (get-tool-schemas)
        (map tool->openai-schema
             (filter
-              (lambda (t) (and t (not (hash-get t "internal"))))
+              (lambda (t)
+                (and t
+                     (not (hash-get t "internal"))
+                     (mode-allows-tool? (hash-ref t "name" ""))))
               (map (lambda (name) (hash-get *tools* name))
                    (hash-keys *tools*)))))
   (def (list-tools) (hash-keys *tools*))
diff --git a/lib/jcode/ui/cli.sls b/lib/jcode/ui/cli.sls
index 2ccbee3..743215e 100644
--- a/lib/jcode/ui/cli.sls
+++ b/lib/jcode/ui/cli.sls
@@ -108,10 +108,13 @@
                         (config-ref "model")
                         (config-default-model
                           (or (current-provider-override)
-                              (config-provider))))])
+                              (config-provider))))]
+             [mode (current-mode)])
          (format
-           "\x1B;[1;34m~a\x1B;[0m > "
-           (model-short-name (or model "?")))))
+           "\x1B;[1;34m~a\x1B;[0m \x1B;[~am~a\x1B;[0m > "
+           (model-short-name (or model "?"))
+           (if (eq? mode 'plan) "1;33" "1;32")
+           (if (eq? mode 'plan) "PLAN" "BUILD"))))
   (def (model-short-name s)
        (let ([idx (string-contains s "/")])
          (if idx (substring s (+ idx 1) (string-length s)) s)))
@@ -169,7 +172,7 @@
          (cond
            [(equal? cmd "help")
             (display
-              "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n")]
+              "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /plan              Switch to PLAN mode (read-only)\n  /build             Switch to BUILD mode (read+write)\n  /mode              Show current mode\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n")]
            [(equal? cmd "model")
             (printf
               "Provider: ~a~n"
@@ -202,6 +205,19 @@
             (printf
               "Available tools: ~a~n"
               (string-join (list-tools) ", "))]
+           [(or (equal? cmd "plan") (equal? cmd "mode plan"))
+            (current-mode 'plan)
+            (printf
+              "\x1B;[1;33mMode: PLAN\x1B;[0m (read-only — write tools disabled)~n")]
+           [(or (equal? cmd "build") (equal? cmd "mode build"))
+            (current-mode 'build)
+            (printf "\x1B;[1;32mMode: BUILD\x1B;[0m (read+write)~n")]
+           [(equal? cmd "mode")
+            (printf
+              "Current mode: ~a~n"
+              (if (eq? (current-mode) 'plan)
+                  "PLAN (read-only)"
+                  "BUILD (read+write)"))]
            [(equal? cmd "clear")
             (let ([new-session (session-create "New session")])
               (printf "Started new session.~n")
diff --git a/lib/jcode/ui/tui-status.sls b/lib/jcode/ui/tui-status.sls
index d4f6917..7d844d4 100644
--- a/lib/jcode/ui/tui-status.sls
+++ b/lib/jcode/ui/tui-status.sls
@@ -11,7 +11,7 @@
     (std misc string) (jcode ui tui-ffi) (jcode ui tui-theme)
     (jerboa core) (jerboa runtime))
   (def (render-status-bar! x y width provider model tokens-in
-         tokens-out cost cwd)
+         tokens-out cost cwd mode)
        "Render the status bar at row y across width columns."
        (let ([bg (face-bg-attr 'status-bar)]
              [fg (face-fg-attr 'status-bar)])
@@ -28,9 +28,13 @@
                 [cost-str (if (> cost 0)
                               (format "$~a" (format-cost cost))
                               "")]
+                [mode-str (if (eq? mode 'plan) "PLAN" "BUILD")]
+                [mode-face (if (eq? mode 'plan)
+                               'status-mode-plan
+                               'status-mode-build)]
                 [left-parts (filter
                               (lambda (p) (not (string-empty? (car p))))
-                              (list
+                              (list (cons mode-str mode-face)
                                 (cons prov-str 'status-provider)
                                 (cons model-str 'status-model)
                                 (cons tok-str 'status-tokens)
diff --git a/lib/jcode/ui/tui-theme.sls b/lib/jcode/ui/tui-theme.sls
index e1c51f7..c7fe966 100644
--- a/lib/jcode/ui/tui-theme.sls
+++ b/lib/jcode/ui/tui-theme.sls
@@ -139,6 +139,12 @@
            (status-dim
              .
              ,(make-face (rgb 160 160 160) (rgb 0 122 204) #f #f #f))
+           (status-mode-build
+             .
+             ,(make-face (rgb 30 30 30) (rgb 78 201 176) #t #f #f))
+           (status-mode-plan
+             .
+             ,(make-face (rgb 30 30 30) (rgb 255 204 0) #t #f #f))
            (sidebar-title
              .
              ,(make-face (rgb 212 212 212) (rgb 37 37 37) #t #f #f))
@@ -277,6 +283,12 @@
            (status-dim
              .
              ,(make-face (rgb 192 192 192) (rgb 0 81 165) #f #f #f))
+           (status-mode-build
+             .
+             ,(make-face (rgb 255 255 255) (rgb 9 124 90) #t #f #f))
+           (status-mode-plan
+             .
+             ,(make-face (rgb 30 30 30) (rgb 255 204 0) #t #f #f))
            (sidebar-title
              .
              ,(make-face (rgb 30 30 30) (rgb 245 245 245) #t #f #f))
@@ -416,6 +428,12 @@
            (status-dim
              .
              ,(make-face (rgb 168 153 132) (rgb 80 73 69) #f #f #f))
+           (status-mode-build
+             .
+             ,(make-face (rgb 40 40 40) (rgb 184 187 38) #t #f #f))
+           (status-mode-plan
+             .
+             ,(make-face (rgb 40 40 40) (rgb 250 189 47) #t #f #f))
            (sidebar-title
              .
              ,(make-face (rgb 235 219 178) (rgb 50 48 47) #t #f #f))
diff --git a/lib/jcode/ui/tui.sls b/lib/jcode/ui/tui.sls
index 0cd8e66..bd673e1 100644
--- a/lib/jcode/ui/tui.sls
+++ b/lib/jcode/ui/tui.sls
@@ -230,6 +230,11 @@
             (tui-log "  -> cycle-theme")
             (cycle-theme!)
             (app-state-dirty?-set! state #t)]
+           [(and (= key TB_KEY_TAB)
+                 (not (zero? (bitwise-and mod TB_MOD_SHIFT))))
+            (tui-log "  -> toggle-mode (shift-tab)")
+            (toggle-mode! state)
+            (app-state-dirty?-set! state #t)]
            [(= key TB_KEY_PGUP)
             (tui-log "  -> page-up")
             (app-state-scroll-offset-set!
@@ -299,6 +304,20 @@
               (add-message! state (msg-block-user text))
               (app-state-scroll-offset-set! state 0)
               (run-agent! state text)]))))
+  (def (set-mode! state new-mode)
+       "Switch to new-mode and announce it in the message stream."
+       (current-mode new-mode)
+       (add-message!
+         state
+         (msg-block-system
+           (if (eq? new-mode 'plan)
+               "Mode: PLAN (read-only). Write tools (write/edit/multi-edit/patch/bash/git_commit) are disabled until you /build."
+               "Mode: BUILD (read+write). All tools available."))))
+  (def (toggle-mode! state)
+       "Toggle between PLAN and BUILD."
+       (set-mode!
+         state
+         (if (eq? (current-mode) 'plan) 'build 'plan)))
   (def (handle-slash-command! state text)
        (let ([cmd (string-trim
                     (substring text 1 (string-length text)))])
@@ -314,13 +333,16 @@
                      "  /provider   Select provider (popup)"
                      "  /provider <name>  Set provider directly"
                      "  /refresh-models   Fetch live model lists from all providers"
+                     "  /plan       Switch to PLAN mode (read-only)"
+                     "  /build      Switch to BUILD mode (read+write)"
+                     "  /mode       Show current mode"
                      "  /tools      List available tools"
                      "  /clear      Start new session"
                      "  /sessions   List sessions"
                      "  /theme      Cycle theme (Ctrl-T)"
                      "  /sidebar    Toggle sidebar (Ctrl-B)"
                      "  /quit       Exit" ""
-                     "Keys: Alt-Enter submit | PgUp/PgDn scroll | Ctrl-C cancel")
+                     "Keys: Alt-Enter submit | PgUp/PgDn scroll | Ctrl-C cancel | Shift-Tab toggle PLAN/BUILD")
                   "\n")))]
            [(equal? cmd "quit") (app-state-quit?-set! state #t)]
            [(equal? cmd "clear")
@@ -340,6 +362,15 @@
                 (string-append
                   "Tools: "
                   (string-join (list-tools) ", "))))]
+           [(equal? cmd "plan") (set-mode! state 'plan)]
+           [(equal? cmd "build") (set-mode! state 'build)]
+           [(equal? cmd "mode")
+            (add-message!
+              state
+              (msg-block-system
+                (if (eq? (current-mode) 'plan)
+                    "Current mode: PLAN (read-only)"
+                    "Current mode: BUILD (read+write)")))]
            [(equal? cmd "model") (handle-model-popup! state)]
            [(equal? cmd "refresh-models")
             (handle-refresh-models! state)]
@@ -758,7 +789,7 @@
            (or (current-provider-override) (config-provider))
            (or (current-model-override) (config-model))
            (app-state-tokens-in state) (app-state-tokens-out state)
-           (app-state-cost state) (current-directory))
+           (app-state-cost state) (current-directory) (current-mode))
          (when (app-state-sidebar-visible? state)
            (render-sidebar! (app-state-sidebar state) (sidebar-x state) 0
              (app-state-sidebar-width state)
diff --git a/src/jcode/core/agent.ss b/src/jcode/core/agent.ss
index adbb8bb..d56e237 100644
--- a/src/jcode/core/agent.ss
+++ b/src/jcode/core/agent.ss
@@ -26,6 +26,7 @@
 (def (system-prompt)
   (format "You are an expert AI coding assistant. You help users with software development tasks.
 Working directory: ~a
+Current mode: ~a
 
 You have access to these tools:
 - read, write, edit, multi-edit: Read and modify files
@@ -35,13 +36,31 @@ You have access to these tools:
 - batch: Run multiple tool calls in parallel
 - git_status, git_diff, git_log, git_show, git_commit: Git operations
 
+~a
+
 When the user asks you to do something:
 1. Think about what tools you need
 2. Use tools to gather information or make changes
 3. Report back with results
 
 Be concise and helpful. When editing files, make minimal targeted changes.
-Prefer using the edit tool over write for modifying existing files." (current-directory)))
+Prefer using the edit tool over write for modifying existing files."
+    (current-directory)
+    (mode-label (current-mode))
+    (mode-instructions (current-mode))))
+
+(def (mode-label m)
+  (case m
+    ((plan)  "PLAN (read-only)")
+    ((build) "BUILD (read+write)")
+    (else    (format "~a" m))))
+
+(def (mode-instructions m)
+  (case m
+    ((plan)
+     "You are in PLAN mode: write/edit/bash/git_commit are disabled. Investigate the codebase with read tools (read, ls, glob, grep, git_status, git_diff, git_log, git_show, fetch) and produce a plan describing what you would change. Do NOT attempt write operations — they will be rejected. Tell the user to switch to BUILD mode (/build) when they want you to apply the plan.")
+    (else
+     "You are in BUILD mode: full read+write access is available.")))
 
 (def current-stream-cb (make-parameter #f))
 (def current-tool-cb (make-parameter #f))
@@ -51,6 +70,17 @@ Prefer using the edit tool over write for modifying existing files." (current-di
 (def *prune-protect-chars* 16000)  ;; ~4k tokens of recent tool results to keep
 (def *tool-result-stub* "[Old tool result cleared]")
 
+(def (refresh-system-prompt messages)
+  "Replace the leading system message (if any) with a fresh one reflecting
+   the current mode. Returns a NEW list — does not mutate. If there's no
+   leading system message, prepends one."
+  (let ((fresh (make-system-message (system-prompt))))
+    (cond
+      ((null? messages) (list fresh))
+      ((equal? (message-role (car messages)) "system")
+       (cons fresh (cdr messages)))
+      (else (cons fresh messages)))))
+
 (def (trim-messages messages)
   "Prune old tool results: walk backwards, protect recent ones, stub the rest."
   (let* ((reversed (reverse messages))
@@ -101,7 +131,7 @@ Prefer using the edit tool over write for modifying existing files." (current-di
 (def (agent-loop session-id messages round)
   (let* ((provider (get-current-provider))
          (tools (get-tool-schemas))
-         (msgs (trim-messages messages))
+         (msgs (trim-messages (refresh-system-prompt messages)))
          (response (provider-chat provider msgs tools)))
     (log-debug logger "got-response" `((role . ,(message-role response))))
     (session-add-message session-id response)
@@ -126,7 +156,7 @@ Prefer using the edit tool over write for modifying existing files." (current-di
   ;; Streaming version: calls (current-stream-cb) for each text token.
   (let* ((provider (get-current-provider))
          (tools    (get-tool-schemas))
-         (msgs     (trim-messages messages)))
+         (msgs     (trim-messages (refresh-system-prompt messages))))
     (let-values (((content tool-calls usage)
                   (provider-stream-chat provider msgs tools (current-stream-cb))))
       (when (and usage (current-usage-cb))
diff --git a/src/jcode/tool/registry.ss b/src/jcode/tool/registry.ss
index f77772e..e8b6fa6 100644
--- a/src/jcode/tool/registry.ss
+++ b/src/jcode/tool/registry.ss
@@ -5,7 +5,12 @@
         tool-execute
         get-tool-schemas
         list-tools
-        tool->openai-schema)
+        tool->openai-schema
+        current-mode
+        mode-allows-tool?
+        mode-blocked-message
+        write-tool-name?
+        register-write-tools!)
 
 (import :std/text/json
         :jcode/core/log)
@@ -14,6 +19,38 @@
 
 (def *tools* (make-hash-table))
 
+;; ---- Mode (plan vs build) ----
+;;
+;; 'build = full read+write access (default)
+;; 'plan  = read-only; write/exec tools are filtered out of the schema list
+;;          AND rejected at execute time as a safety net.
+(def current-mode (make-parameter 'build))
+
+;; Names of tools that mutate state (filesystem, processes, network sends,
+;; git commits). Read-only tools (read, ls, glob, grep, git_status, git_diff,
+;; git_log, git_show, fetch with GET) are NOT in this set.
+(def *write-tools*
+  '("write" "edit" "multi-edit" "patch" "bash" "git_commit"))
+
+(def (write-tool-name? name)
+  (and (member name *write-tools*) #t))
+
+(def (mode-allows-tool? name)
+  (or (eq? (current-mode) 'build)
+      (not (write-tool-name? name))))
+
+(def (mode-blocked-message name)
+  (format "Tool '~a' is blocked in PLAN mode (read-only). Switch to BUILD mode with /build to run write tools."
+    name))
+
+(def (register-write-tools! . names)
+  ;; Allow tool modules to mark additional tool names as writers (e.g. plugins).
+  (for-each
+    (lambda (n)
+      (unless (member n *write-tools*)
+        (set! *write-tools* (cons n *write-tools*))))
+    names))
+
 (def (register-tool! name description schema handler)
   (hash-put! *tools* name
     (make-hash-table-from-alist
@@ -46,20 +83,27 @@
         (substring str (- (string-length str) keep) (string-length str))))))
 
 (def (tool-execute name args)
-  (log-info logger "execute" `((name . ,name)))
-  (let ((tool (hash-get *tools* name)))
-    (if tool
-      (let ((result (try
-                      ((hash-ref tool "handler") args)
-                      (catch (e)
-                        (format "Error executing ~a: ~a" name (err->string e))))))
-        (truncate-result result))
-      (format "Unknown tool: ~a" name))))
+  (log-info logger "execute" `((name . ,name) (mode . ,(current-mode))))
+  (cond
+    ((not (mode-allows-tool? name))
+     (log-warn logger "blocked-by-mode" `((name . ,name) (mode . ,(current-mode))))
+     (mode-blocked-message name))
+    (else
+     (let ((tool (hash-get *tools* name)))
+       (if tool
+         (let ((result (try
+                         ((hash-ref tool "handler") args)
+                         (catch (e)
+                           (format "Error executing ~a: ~a" name (err->string e))))))
+           (truncate-result result))
+         (format "Unknown tool: ~a" name))))))
 
 (def (get-tool-schemas)
   (map tool->openai-schema
        (filter (lambda (t)
-                 (and t (not (hash-get t "internal"))))
+                 (and t
+                      (not (hash-get t "internal"))
+                      (mode-allows-tool? (hash-ref t "name" ""))))
                (map (lambda (name) (hash-get *tools* name))
                     (hash-keys *tools*)))))
 
diff --git a/src/jcode/ui/cli.ss b/src/jcode/ui/cli.ss
index eee3d7d..9ceefb8 100644
--- a/src/jcode/ui/cli.ss
+++ b/src/jcode/ui/cli.ss
@@ -133,8 +133,12 @@ EXAMPLES:
 (def (make-prompt)
   (let ((provider (or (current-provider-override) (config-provider)))
         (model (or (current-model-override) (config-ref "model")
-                   (config-default-model (or (current-provider-override) (config-provider))))))
-    (format "\x1b;[1;34m~a\x1b;[0m > " (model-short-name (or model "?")))))
+                   (config-default-model (or (current-provider-override) (config-provider)))))
+        (mode  (current-mode)))
+    (format "\x1b;[1;34m~a\x1b;[0m \x1b;[~am~a\x1b;[0m > "
+      (model-short-name (or model "?"))
+      (if (eq? mode 'plan) "1;33" "1;32")
+      (if (eq? mode 'plan) "PLAN" "BUILD"))))
 
 (def (model-short-name s)
   ;; Extract last segment: "anthropic/claude-sonnet-4" → "claude-sonnet-4"
@@ -178,7 +182,7 @@ EXAMPLES:
   (let ((cmd (string-trim (substring input 1 (string-length input)))))
     (cond
       ((equal? cmd "help")
-       (display "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n"))
+       (display "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /plan              Switch to PLAN mode (read-only)\n  /build             Switch to BUILD mode (read+write)\n  /mode              Show current mode\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n"))
       ((equal? cmd "model")
        (printf "Provider: ~a~n" (or (current-provider-override) (config-provider)))
        (printf "Model:    ~a~n" (or (current-model-override) (config-model)))
@@ -196,6 +200,15 @@ EXAMPLES:
          (printf "Provider set to: ~a (model: ~a)~n" new-provider (current-model-override))))
       ((equal? cmd "tools")
        (printf "Available tools: ~a~n" (string-join (list-tools) ", ")))
+      ((or (equal? cmd "plan") (equal? cmd "mode plan"))
+       (current-mode 'plan)
+       (printf "\x1b;[1;33mMode: PLAN\x1b;[0m (read-only — write tools disabled)~n"))
+      ((or (equal? cmd "build") (equal? cmd "mode build"))
+       (current-mode 'build)
+       (printf "\x1b;[1;32mMode: BUILD\x1b;[0m (read+write)~n"))
+      ((equal? cmd "mode")
+       (printf "Current mode: ~a~n"
+         (if (eq? (current-mode) 'plan) "PLAN (read-only)" "BUILD (read+write)")))
       ((equal? cmd "clear")
        (let ((new-session (session-create "New session")))
          (printf "Started new session.~n")
diff --git a/src/jcode/ui/tui-status.ss b/src/jcode/ui/tui-status.ss
index 7e0c264..445c782 100644
--- a/src/jcode/ui/tui-status.ss
+++ b/src/jcode/ui/tui-status.ss
@@ -7,7 +7,7 @@
         :jcode/ui/tui-ffi
         :jcode/ui/tui-theme)
 
-(def (render-status-bar! x y width provider model tokens-in tokens-out cost cwd)
+(def (render-status-bar! x y width provider model tokens-in tokens-out cost cwd mode)
   "Render the status bar at row y across width columns."
   (let ((bg (face-bg-attr 'status-bar))
         (fg (face-fg-attr 'status-bar)))
@@ -17,17 +17,20 @@
         (tb-change-cell! col y (char->integer #\space) fg bg)
         (loop (+ col 1))))
 
-    ;; Left side: provider │ model │ tokens │ cost
-    (let* ((prov-str (or provider "?"))
+    ;; Left side: mode │ provider │ model │ tokens │ cost
+    (let* ((prov-str  (or provider "?"))
            (model-str (model-short-name (or model "?")))
-           (tok-str (format "~a/~a" (format-count tokens-in) (format-count tokens-out)))
-           (cost-str (if (> cost 0) (format "$~a" (format-cost cost)) ""))
+           (tok-str   (format "~a/~a" (format-count tokens-in) (format-count tokens-out)))
+           (cost-str  (if (> cost 0) (format "$~a" (format-cost cost)) ""))
+           (mode-str  (if (eq? mode 'plan) "PLAN" "BUILD"))
+           (mode-face (if (eq? mode 'plan) 'status-mode-plan 'status-mode-build))
            (left-parts
              (filter (lambda (p) (not (string-empty? (car p))))
-               (list (cons prov-str 'status-provider)
+               (list (cons mode-str  mode-face)
+                     (cons prov-str  'status-provider)
                      (cons model-str 'status-model)
-                     (cons tok-str 'status-tokens)
-                     (cons cost-str 'status-cost)))))
+                     (cons tok-str   'status-tokens)
+                     (cons cost-str  'status-cost)))))
       (let lloop ((parts left-parts) (col (+ x 1)))
         (when (pair? parts)
           (let* ((part (car parts))
diff --git a/src/jcode/ui/tui-theme.ss b/src/jcode/ui/tui-theme.ss
index 677319f..d0bec38 100644
--- a/src/jcode/ui/tui-theme.ss
+++ b/src/jcode/ui/tui-theme.ss
@@ -92,6 +92,8 @@
       (status-tokens     . ,(make-face (rgb #xd4 #xd4 #xd4) (rgb #x00 #x7a #xcc) #f #f #f))
       (status-cost       . ,(make-face (rgb #xdc #xdc #xaa) (rgb #x00 #x7a #xcc) #f #f #f))
       (status-dim        . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #x00 #x7a #xcc) #f #f #f))
+      (status-mode-build . ,(make-face (rgb #x1e #x1e #x1e) (rgb #x4e #xc9 #xb0) #t #f #f))
+      (status-mode-plan  . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xff #xcc #x00) #t #f #f))
       (sidebar-title     . ,(make-face (rgb #xd4 #xd4 #xd4) (rgb #x25 #x25 #x25) #t #f #f))
       (sidebar-item      . ,(make-face (rgb #xa0 #xa0 #xa0) (rgb #x25 #x25 #x25) #f #f #f))
       (sidebar-selected  . ,(make-face (rgb #xff #xff #xff) (rgb #x3a #x3a #x5a) #f #f #f))
@@ -142,6 +144,8 @@
       (status-tokens     . ,(make-face (rgb #xe0 #xe0 #xe0) (rgb #x00 #x51 #xa5) #f #f #f))
       (status-cost       . ,(make-face (rgb #xdc #xdc #xaa) (rgb #x00 #x51 #xa5) #f #f #f))
       (status-dim        . ,(make-face (rgb #xc0 #xc0 #xc0) (rgb #x00 #x51 #xa5) #f #f #f))
+      (status-mode-build . ,(make-face (rgb #xff #xff #xff) (rgb #x09 #x7c #x5a) #t #f #f))
+      (status-mode-plan  . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xff #xcc #x00) #t #f #f))
       (sidebar-title     . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xf5 #xf5 #xf5) #t #f #f))
       (sidebar-item      . ,(make-face (rgb #x40 #x40 #x40) (rgb #xf5 #xf5 #xf5) #f #f #f))
       (sidebar-selected  . ,(make-face (rgb #x1e #x1e #x1e) (rgb #xe0 #xe0 #xf0) #f #f #f))
@@ -192,6 +196,8 @@
       (status-tokens     . ,(make-face (rgb #xeb #xdb #xb2) (rgb #x50 #x49 #x45) #f #f #f))
       (status-cost       . ,(make-face (rgb #xfa #xbd #x2f) (rgb #x50 #x49 #x45) #f #f #f))
       (status-dim        . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x50 #x49 #x45) #f #f #f))
+      (status-mode-build . ,(make-face (rgb #x28 #x28 #x28) (rgb #xb8 #xbb #x26) #t #f #f))
+      (status-mode-plan  . ,(make-face (rgb #x28 #x28 #x28) (rgb #xfa #xbd #x2f) #t #f #f))
       (sidebar-title     . ,(make-face (rgb #xeb #xdb #xb2) (rgb #x32 #x30 #x2f) #t #f #f))
       (sidebar-item      . ,(make-face (rgb #xa8 #x99 #x84) (rgb #x32 #x30 #x2f) #f #f #f))
       (sidebar-selected  . ,(make-face (rgb #xfb #xf1 #xc7) (rgb #x50 #x49 #x45) #f #f #f))
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index 68a470e..c9d4784 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -327,6 +327,12 @@
        (cycle-theme!)
        (app-state-dirty?-set! state #t))
 
+      ;; Global: Shift-Tab toggle PLAN/BUILD mode (opencode-style)
+      ((and (= key TB_KEY_TAB) (not (zero? (bitwise-and mod TB_MOD_SHIFT))))
+       (tui-log "  -> toggle-mode (shift-tab)")
+       (toggle-mode! state)
+       (app-state-dirty?-set! state #t))
+
       ;; Global: Page-up/down scroll
       ((= key TB_KEY_PGUP)
        (tui-log "  -> page-up")
@@ -398,6 +404,19 @@
          ;; Run agent (draws spinner before blocking API call)
          (run-agent! state text))))))
 
+(def (set-mode! state new-mode)
+  "Switch to new-mode and announce it in the message stream."
+  (current-mode new-mode)
+  (add-message! state
+    (msg-block-system
+      (if (eq? new-mode 'plan)
+        "Mode: PLAN (read-only). Write tools (write/edit/multi-edit/patch/bash/git_commit) are disabled until you /build."
+        "Mode: BUILD (read+write). All tools available."))))
+
+(def (toggle-mode! state)
+  "Toggle between PLAN and BUILD."
+  (set-mode! state (if (eq? (current-mode) 'plan) 'build 'plan)))
+
 (def (handle-slash-command! state text)
   (let ((cmd (string-trim (substring text 1 (string-length text)))))
     (cond
@@ -412,6 +431,9 @@
                "  /provider   Select provider (popup)"
                "  /provider <name>  Set provider directly"
                "  /refresh-models   Fetch live model lists from all providers"
+               "  /plan       Switch to PLAN mode (read-only)"
+               "  /build      Switch to BUILD mode (read+write)"
+               "  /mode       Show current mode"
                "  /tools      List available tools"
                "  /clear      Start new session"
                "  /sessions   List sessions"
@@ -419,7 +441,7 @@
                "  /sidebar    Toggle sidebar (Ctrl-B)"
                "  /quit       Exit"
                ""
-               "Keys: Alt-Enter submit | PgUp/PgDn scroll | Ctrl-C cancel")
+               "Keys: Alt-Enter submit | PgUp/PgDn scroll | Ctrl-C cancel | Shift-Tab toggle PLAN/BUILD")
              "\n"))))
       ((equal? cmd "quit")
        (app-state-quit?-set! state #t))
@@ -435,6 +457,16 @@
       ((equal? cmd "tools")
        (add-message! state
          (msg-block-system (string-append "Tools: " (string-join (list-tools) ", ")))))
+      ((equal? cmd "plan")
+       (set-mode! state 'plan))
+      ((equal? cmd "build")
+       (set-mode! state 'build))
+      ((equal? cmd "mode")
+       (add-message! state
+         (msg-block-system
+           (if (eq? (current-mode) 'plan)
+             "Current mode: PLAN (read-only)"
+             "Current mode: BUILD (read+write)"))))
       ((equal? cmd "model")
        (handle-model-popup! state))
       ((equal? cmd "refresh-models")
@@ -835,7 +867,8 @@
       (app-state-tokens-in state)
       (app-state-tokens-out state)
       (app-state-cost state)
-      (current-directory))
+      (current-directory)
+      (current-mode))
 
     ;; Sidebar
     (when (app-state-sidebar-visible? state)