Skip to content

Commit fe79cbc

Browse files
committed
Give each tab its own keyboard input source
Switching tabs now restores the input method that tab was last left in, so a CLAUDE.md tab can stay on a CJK source while a shadow tab defaults to English. The system input source is only touched while the panel holds focus, so other apps keep their own input method. Per-tab sources persist for xcode/pinned tabs and are gated behind a Settings toggle (default on).
1 parent afbd920 commit fe79cbc

8 files changed

Lines changed: 136 additions & 13 deletions

File tree

Notchy.xcodeproj/project.pbxproj

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@
1515
31844E332F7210B20074F7B7 /* Notchy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Notchy.app; sourceTree = BUILT_PRODUCTS_DIR; };
1616
/* End PBXFileReference section */
1717

18+
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
19+
BD0000070000000000000007 /* Exceptions for "Notchy" folder in "Notchy" target */ = {
20+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
21+
membershipExceptions = (
22+
Info.plist,
23+
);
24+
target = 31844E322F7210B20074F7B7 /* Notchy */;
25+
};
26+
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
27+
1828
/* Begin PBXFileSystemSynchronizedRootGroup section */
1929
31844E352F7210B20074F7B7 /* Notchy */ = {
2030
isa = PBXFileSystemSynchronizedRootGroup;
@@ -26,16 +36,6 @@
2636
};
2737
/* End PBXFileSystemSynchronizedRootGroup section */
2838

29-
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
30-
BD0000070000000000000007 /* Exceptions for "Notchy" folder in "Notchy" target */ = {
31-
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
32-
membershipExceptions = (
33-
Info.plist,
34-
);
35-
target = 31844E322F7210B20074F7B7 /* Notchy */;
36-
};
37-
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
38-
3939
/* Begin PBXFrameworksBuildPhase section */
4040
31844E302F7210B20074F7B7 /* Frameworks */ = {
4141
isa = PBXFrameworksBuildPhase;

Notchy/InputSourceManager.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Carbon
2+
3+
/// Thin wrapper around the Text Input Sources (TIS) API used to give each tab
4+
/// its own keyboard input source. macOS only tracks a single system-wide input
5+
/// source at a time, so "per-tab input methods" is simulated by capturing the
6+
/// live source when leaving a tab and re-selecting the stored one when entering.
7+
enum InputSourceManager {
8+
/// The currently selected keyboard input source ID (e.g.
9+
/// "com.apple.keylayout.ABC" or "com.apple.inputmethod.SCIM.ITABC").
10+
static func currentSourceID() -> String? {
11+
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return nil }
12+
return sourceID(of: source)
13+
}
14+
15+
/// Select the input source with the given ID. No-op if it can't be found
16+
/// (e.g. the user removed that input source since it was recorded).
17+
static func select(id: String) {
18+
guard let source = source(withID: id) else { return }
19+
TISSelectInputSource(source)
20+
}
21+
22+
/// Switch to an ASCII-capable (English) keyboard layout. Uses the most
23+
/// recently used ASCII source, which is what the user expects as "English".
24+
static func selectASCIICapable() {
25+
guard let source = TISCopyCurrentASCIICapableKeyboardInputSource()?.takeRetainedValue() else { return }
26+
TISSelectInputSource(source)
27+
}
28+
29+
private static func sourceID(of source: TISInputSource) -> String? {
30+
guard let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return nil }
31+
return Unmanaged<CFString>.fromOpaque(ptr).takeUnretainedValue() as String
32+
}
33+
34+
private static func source(withID id: String) -> TISInputSource? {
35+
let filter = [kTISPropertyInputSourceID as String: id] as CFDictionary
36+
guard let list = TISCreateInputSourceList(filter, false)?.takeRetainedValue() as? [TISInputSource] else {
37+
return nil
38+
}
39+
return list.first
40+
}
41+
}

Notchy/SessionStore.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,25 @@ class SessionStore {
1919
guard let newValue = activeSessionId, newValue != oldValue else { return }
2020
activationHistory.removeAll { $0 == newValue }
2121
activationHistory.append(newValue)
22+
// Per-tab input source: hand the outgoing tab's live input method off
23+
// to the incoming tab. Only while the panel holds focus, so we never
24+
// change the system input source out from under another app.
25+
if SettingsManager.shared.perTabInputSourceEnabled && isPanelKey {
26+
captureInputSource(into: oldValue)
27+
applyInputSource(for: newValue)
28+
}
2229
}
2330
}
2431
/// Stack of session IDs in the order they became active, most-recent last.
2532
/// On close, the previously active tab (browser-style) is restored.
2633
private var activationHistory: [UUID] = []
34+
35+
/// True while the terminal panel is the key window. Gates per-tab input
36+
/// source switching so we only touch the system input source while focused.
37+
private var isPanelKey = false
38+
/// Input source in effect outside Notchy, captured when the panel gains
39+
/// focus and restored when it loses focus, so other apps keep their IME.
40+
private var externalInputSource: String?
2741
var isPinned: Bool = {
2842
if UserDefaults.standard.object(forKey: "isPinned") == nil { return true }
2943
return UserDefaults.standard.bool(forKey: "isPinned")
@@ -121,7 +135,7 @@ class SessionStore {
121135
// Normal "+" tabs are ephemeral by design — only xcode and pinned tabs survive a restart.
122136
let persisted = sessions
123137
.filter { $0.kind != .normal }
124-
.map { PersistedSession(id: $0.id, projectName: $0.projectName, customName: $0.customName, projectPath: $0.projectPath, workingDirectory: $0.workingDirectory, kind: $0.kind) }
138+
.map { PersistedSession(id: $0.id, projectName: $0.projectName, customName: $0.customName, projectPath: $0.projectPath, workingDirectory: $0.workingDirectory, kind: $0.kind, inputSource: $0.inputSource) }
125139
if let data = try? JSONEncoder().encode(persisted) {
126140
UserDefaults.standard.set(data, forKey: Self.sessionsKey)
127141
}
@@ -154,6 +168,49 @@ class SessionStore {
154168
/// Called when the panel gains focus — trigger a fresh Xcode scan
155169
func panelDidBecomeKey() {
156170
detectAllXcodeProjectsAsync()
171+
let wasKey = isPanelKey
172+
isPanelKey = true
173+
// Skip when returning from an in-Notchy sheet/dialog (already key) so we
174+
// don't clobber the saved external source with our own applied one.
175+
guard SettingsManager.shared.perTabInputSourceEnabled, !wasKey else { return }
176+
externalInputSource = InputSourceManager.currentSourceID()
177+
applyInputSource(for: activeSessionId)
178+
}
179+
180+
/// Called when the panel truly loses focus to another app (not an in-Notchy
181+
/// sheet). Remembers the active tab's input source and restores the external
182+
/// app's input source so we never leave another app stuck in English.
183+
func panelDidResignKey() {
184+
isPanelKey = false
185+
guard SettingsManager.shared.perTabInputSourceEnabled else { return }
186+
captureInputSource(into: activeSessionId)
187+
if let external = externalInputSource {
188+
InputSourceManager.select(id: external)
189+
externalInputSource = nil
190+
}
191+
}
192+
193+
/// Save the live system input source into the given tab's record.
194+
private func captureInputSource(into sessionID: UUID?) {
195+
guard let id = sessionID,
196+
let index = sessions.firstIndex(where: { $0.id == id }),
197+
let current = InputSourceManager.currentSourceID(),
198+
sessions[index].inputSource != current else { return }
199+
sessions[index].inputSource = current
200+
persistSessions()
201+
}
202+
203+
/// Switch the system input source to match the given tab. A never-visited
204+
/// "+" tab defaults to English; xcode/pinned tabs inherit the current source
205+
/// on first visit (and remember whatever they're left in thereafter).
206+
private func applyInputSource(for sessionID: UUID?) {
207+
guard let id = sessionID,
208+
let session = sessions.first(where: { $0.id == id }) else { return }
209+
if let saved = session.inputSource {
210+
InputSourceManager.select(id: saved)
211+
} else if session.kind == .normal {
212+
InputSourceManager.selectASCIICapable()
213+
}
157214
}
158215

159216
/// Scans for all open Xcode projects — adds new ones, updates active set.

Notchy/SettingsManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class SettingsManager {
8282
didSet { UserDefaults.standard.set(terminalLigaturesEnabled, forKey: "terminalLigaturesEnabled") }
8383
}
8484

85+
/// Give each tab its own keyboard input source: switching tabs restores the
86+
/// input method that tab was last left in; new "+" tabs default to English.
87+
var perTabInputSourceEnabled: Bool {
88+
didSet { UserDefaults.standard.set(perTabInputSourceEnabled, forKey: "perTabInputSourceEnabled") }
89+
}
90+
8591
static let minBufferSize = 500
8692
static let maxBufferSize = 50000
8793
static let defaultBufferSize = 1000
@@ -114,6 +120,7 @@ class SettingsManager {
114120
if defaults.object(forKey: "terminalBufferSize") == nil { defaults.set(Self.defaultBufferSize, forKey: "terminalBufferSize") }
115121
if defaults.object(forKey: "terminalFontWeight") == nil { defaults.set(TerminalFontWeight.regular.rawValue, forKey: "terminalFontWeight") }
116122
if defaults.object(forKey: "terminalLigaturesEnabled") == nil { defaults.set(true, forKey: "terminalLigaturesEnabled") }
123+
if defaults.object(forKey: "perTabInputSourceEnabled") == nil { defaults.set(true, forKey: "perTabInputSourceEnabled") }
117124

118125
showNotch = defaults.bool(forKey: "replaceNotch")
119126
soundsEnabled = defaults.bool(forKey: "soundsEnabled")
@@ -130,6 +137,7 @@ class SettingsManager {
130137
terminalFontSize = storedFontSize > 0 ? CGFloat(storedFontSize) : 13
131138
terminalFontWeight = TerminalFontWeight(rawValue: defaults.string(forKey: "terminalFontWeight") ?? "") ?? .regular
132139
terminalLigaturesEnabled = defaults.bool(forKey: "terminalLigaturesEnabled")
140+
perTabInputSourceEnabled = defaults.bool(forKey: "perTabInputSourceEnabled")
133141
let storedBufferSize = defaults.integer(forKey: "terminalBufferSize")
134142
terminalBufferSize = storedBufferSize > 0
135143
? max(Self.minBufferSize, min(Self.maxBufferSize, storedBufferSize))

Notchy/SettingsWindow.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ struct GeneralTab: View {
113113
.font(.caption)
114114
.foregroundStyle(.secondary)
115115
}
116+
Toggle(isOn: $settings.perTabInputSourceEnabled) {
117+
Text("Per-tab input source")
118+
Text("Each tab remembers its own keyboard input method; new \"+\" tabs default to English")
119+
.font(.caption)
120+
.foregroundStyle(.secondary)
121+
}
116122
BufferSizeRow()
117123
}
118124
}

Notchy/TerminalPanel.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,13 @@ class TerminalPanel: NSPanel, NSWindowDelegate {
299299
}
300300

301301
@objc func windowDidResignKey(_ notification: Notification) {
302-
if !sessionStore.isPinned && !sessionStore.isShowingDialog && attachedSheet == nil && childWindows?.isEmpty ?? true {
303-
hidePanel()
302+
// Focus moving to an in-Notchy sheet/dialog isn't really "leaving".
303+
let focusStillInNotchy = sessionStore.isShowingDialog || attachedSheet != nil || !(childWindows?.isEmpty ?? true)
304+
if !focusStillInNotchy {
305+
sessionStore.panelDidResignKey()
306+
if !sessionStore.isPinned {
307+
hidePanel()
308+
}
304309
}
305310
updateOpacity()
306311
}

Notchy/TerminalSession.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ struct TerminalSession: Identifiable {
8686
/// When the session most recently entered the .working state
8787
var workingStartedAt: Date?
8888
var kind: TabKind
89+
/// TIS input source ID this tab was last left in, restored on re-select.
90+
/// nil until the tab has been visited (see SessionStore input-source logic).
91+
var inputSource: String?
8992

9093
var displayName: String { customName ?? projectName }
9194

@@ -117,6 +120,7 @@ struct TerminalSession: Identifiable {
117120
self.createdAt = Date()
118121
// Migration: older persisted records have no kind — infer from projectPath
119122
self.kind = persisted.kind ?? (persisted.projectPath != nil ? .xcode : .normal)
123+
self.inputSource = persisted.inputSource
120124
}
121125
}
122126

@@ -128,4 +132,5 @@ struct PersistedSession: Codable {
128132
let projectPath: String?
129133
let workingDirectory: String
130134
let kind: TabKind?
135+
let inputSource: String?
131136
}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ A macOS menu bar app that puts Claude Code or Codex right in your MacBook's notc
1919
- **Ligature toggle** — Settings → General → Terminal → Ligatures switches off OpenType `calt` and `liga` substitutions, so `===`, `=>`, `!=` etc. render as plain characters instead of combined glyphs
2020
- **Force-click to look up** — deep-press a word in the terminal to pop up its system dictionary definition, just like Safari's Look Up; toggle via Settings → General → Terminal → Look up on force click
2121
- **Right-click menu** — right-click in the terminal for Copy / Paste / Select All, look up or web-search the selection (or the word under the cursor), open a URL under the cursor, and clear the screen
22+
- **Per-tab input source** — each tab remembers its own keyboard input method, so a CLAUDE.md tab can stay on a CJK input source while a shadow tab defaults to English; switches automatically as you change tabs and leaves other apps' input source untouched. Toggle via Settings → General → Terminal → Per-tab input source
2223
- **Adjustable scrollback** — set the terminal history buffer in Settings → General → Terminal (default 1,000 lines, up to 50,000)
2324
- **Live status in the notch** — animated pill shows whether the agent is working, waiting, or done
2425
- **Git checkpoints** — Cmd+S to snapshot your project before the agent makes changes

0 commit comments

Comments
 (0)